diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..426260a --- /dev/null +++ b/.firebaserc @@ -0,0 +1,7 @@ +{ + "projects": { + "default": "worktrack-prod", + "prod": "worktrack-prod", + "demo": "worktrack-demo-af" + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c269f9b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,94 @@ +name: CI + +# Every bug that reached production this month was catchable by a test that +# already existed but that nothing ran automatically. This runs them. +on: + push: + pull_request: + +# A new push to the same branch makes the previous run irrelevant. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend: + name: Backend (functions) + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend/functions + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" # matches the Cloud Functions runtime + cache: npm + cache-dependency-path: backend/functions/package-lock.json + + # The Firestore emulator is a Java process, and current firebase-tools + # refuses to start on anything below JDK 21. This is unrelated to the + # Android toolchain below, which is pinned to 17 by the Gradle config. + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - run: npm ci + - run: npm run typecheck + - run: npm test + + # Integration tests skip themselves without an emulator, so they need one + # started explicitly or they would silently pass by doing nothing. + - name: Integration tests (Firestore emulator) + run: | + npm install --global firebase-tools + npm run test:integration + + web: + name: Web (manager portal) + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: web/package-lock.json + + - run: npm ci + - run: npm run typecheck + - run: npm test + # Catches what typecheck cannot: a broken import or a template that only + # fails when Vite actually bundles it. + - run: npm run build + + android: + name: Android + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - uses: gradle/actions/setup-gradle@v4 + + # google-services.json is never committed; the app module applies the + # plugin only when it is present, so the build works without it. + - run: ./gradlew compileDebugKotlin testDebugUnitTest lintDebug --no-daemon + + - name: Upload lint report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: lint-report + path: app/build/reports/lint-results-debug.html + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da329df --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar + +# IDE +.idea/ +*.iml +.DS_Store +local.properties +captures/ +.externalNativeBuild/ +.cxx/ + +# Secrets / environment-specific configuration (never commit) +app/google-services.json +app/src/demo/google-services.json +backend/.firebaserc +backend/functions/.env* +backend/functions/.secret.local +web/.env.local +*.keystore +*.jks +# Holds the release signing password. +keystore.properties + +# Node +node_modules/ +backend/functions/lib/ +npm-debug.log* + +# Third-party Claude skills (installed locally, not vendored) +.claude/skills/ + +# Firebase local artifacts: emulator logs and the hosting upload cache. +# Regenerated on every run/deploy, and machine-specific. +.firebase/ +firestore-debug.log +firebase-debug.log +ui-debug.log + +# Release build output — APKs and baseline profiles. Large, regenerated +# by every build, and never worth keeping in history. +app/release/ + +# Kotlin compiler scratch and error logs. +.kotlin/ diff --git a/README.md b/README.md index 7816061..0fecc97 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,150 @@ -# WorkTrack -Smart Workforce & Attendance Management +# WorkTrack — Smart Workforce & Attendance Management + +**ورک‌ترک — مدیریت هوشمند نیروی کار برای افغانستان.** پلتفرم به زبان‌های **دری** +(پیش‌فرض) و **پښتو** و انگلیسی است؛ تاریخ‌ها و دوره‌های معاش بر اساس تقویم +**هجری شمسی** با نام ماه‌های افغانستان (حمل، ثور، جوزا…) نمایش داده می‌شود و رخصتی +هفته‌وار روز جمعه است. + +WorkTrack is a multi-tenant Workforce Management Platform (HRMS) **built for +Afghanistan**: attendance with GPS geofencing and kiosk QR check-in, shift +scheduling, leave management with approval chains, payroll, announcements, +analytics, and enterprise-grade security — designed for organizations from small +teams to 100,000+ employees. Dari is the default language (full Pashto and English +translations, RTL-first UI), and all dates/payroll periods use the Solar Hijri +calendar — see `docs/10-localization-afghanistan.md`. + +**Two products, one backend.** Each company self-registers and gets its own +isolated workspace (multi-tenant): + +| Product | Audience | Where | +|---|---|---| +| **Company Console** | managers, HR, payroll | web portal (`web/`) — dashboard, employees, attendance, leave, payroll | +| **Employee App** | employees | Android (`app/`) + iOS (`ios/`) — attendance, leave, payslips | + +To take it live, see **[docs/12-production-deployment.md](docs/12-production-deployment.md)**. +To try it locally with sample data, see **[docs/11-local-demo-setup.md](docs/11-local-demo-setup.md)**. + +## Repository layout + +| Path | Contents | +|---|---| +| `docs/` | Complete design documentation (start at `docs/00-master-spec.md`) | +| `app/`, `core/`, `feature/` | Android app — Kotlin, Jetpack Compose (M3), MVVM + Clean Architecture, Hilt, Room, WorkManager, offline-first sync | +| `build-logic/` | Gradle convention plugins shared by all modules | +| `backend/` | Firebase backend — REST API v1 on Cloud Functions (TypeScript/Express), Firestore rules and indexes | +| `web/` | Manager portal (web admin) — React + TypeScript + Vite, Dari/Pashto/English, Solar Hijri | +| `ios/` | iOS app — SwiftUI, XcodeGen + CocoaPods, TensorFlowLite face recognition | +| `desktop/` | Desktop shell — Electron wrapper around the web portal (Windows) | +| `delivery/` | Store assets — Play Store icon, feature graphic, screenshots | +| `scripts/` | One-off admin scripts (licence provisioning, etc.) | + +## Design documentation + +1. [Master specification (source of truth)](docs/00-master-spec.md) +2. [Product requirements](docs/01-product-requirements.md) +3. [System architecture](docs/02-system-architecture.md) +4. [Database design & ER diagrams](docs/03-database-design.md) +5. [REST API design](docs/04-api-design.md) +6. [Android architecture & navigation](docs/05-android-architecture.md) +7. [Web admin console design](docs/06-web-admin-design.md) +8. [Security architecture](docs/07-security-architecture.md) +9. [Offline-first sync strategy](docs/08-sync-strategy.md) +10. [Development roadmap](docs/09-roadmap.md) +11. [Afghanistan localization (دری/پښتو, Solar Hijri)](docs/10-localization-afghanistan.md) +12. [Local demo setup — run everything with sample data](docs/11-local-demo-setup.md) +13. [Production deployment — take it live](docs/12-production-deployment.md) +14. [Operations runbook — diagnosing and repairing a live system](docs/13-operations-runbook.md) +15. [The hosted demo](docs/14-hosted-demo.md) +16. [اپلیکیشن iOS — امکان‌سنجی و برنامه](docs/15-ios-app.md) +17. [Business types and feature gaps](docs/16-business-types-and-gaps.md) +18. [Google Play submission](docs/17-google-play.md) + +## Android app + +Module graph (details in `docs/05-android-architecture.md`): + +``` +app → feature:{auth,dashboard,attendance,leave,payslips,profile} + → core:{data,sync} → core:{database,network,datastore} → core:{domain,model,common} + → core:designsystem +``` + +Key properties: + +- **Offline-first**: Room is the local source of truth; mutations queue in an outbox + with ULID idempotency keys and sync via WorkManager (`core/sync`). Punches are + append-only; the server is authoritative for balances, attendance days, payroll. +- **Attendance**: GPS punch with client+server geofence validation, mock-location + rejection, kiosk TOTP QR scanning (CameraX + ML Kit), monthly history. +- **Leave**: balances, apply flow with half-days, approver inbox with approve/reject. +- **Security**: Firebase Auth ID tokens, tenant/RBAC custom claims, no tokens stored + outside the Firebase SDK, cloud backup disabled for tenant data. + +### Building + +Prerequisites: JDK 17+, Android SDK 36. The Gradle wrapper is pinned (8.13). + +```bash +./gradlew :app:assembleDebug +./gradlew test # JVM unit tests (domain/common) +``` + +### Firebase setup (required to run the app) + +The app authenticates with Firebase, so it needs a `google-services.json`. Without +it the app still launches to the login screen, but sign-in fails. To wire it up: + +1. Create a Firebase project at . +2. Add Android app(s) to it. **The debug build's application id is + `app.worktrack.debug`** (the `.debug` suffix is added by the debug build type), + so register that package name to run debug builds. Add `app.worktrack` too for + release builds — both clients end up in the same `google-services.json`. +3. Download `google-services.json` and put it in the **`app/`** directory + (`WorkTrack/app/google-services.json`). The Google Services Gradle plugin is + applied automatically when the file is present (see the bottom of + `app/build.gradle.kts`), which generates the default `FirebaseOptions` that + `FirebaseApp` initializes from at startup. +4. In the Firebase console, enable **Authentication → Sign-in method → + Email/Password**. +5. Rebuild and run. + +The file is git-ignored (it's per-environment config). Debug builds point the API +at the local Functions emulator (`app/build.gradle.kts` → `API_BASE_URL`); run the +backend emulator (see below) and provision a tenant to sign in end-to-end. + +## Backend + +```bash +cd backend/functions +npm install +npm run typecheck # strict TypeScript +npm run serve # Firebase emulators: functions + firestore + auth +``` + +- REST API v1 (Express on Cloud Functions v2): `me`, `attendance` (punch validation: + geofence, kiosk HMAC token, speed-of-travel plausibility), `leave` (transactional + balance reservation + approval chain), `payslips`, `announcements`, and the + sync protocol (`POST /sync/push`, `GET /sync/pull` with per-type delta cursors). +- Firestore rules deny all direct client access — every read/write goes through the + API (deny-by-default RBAC middleware, RFC 7807 errors, audit log on privileged ops). +- Kiosk QR secret: `firebase functions:secrets:set KIOSK_HMAC_SECRET`. + +## Provisioning a tenant (P0) + +1. Create `companies/{cid}` with `name`, `timezone`, `currency`. +2. Create `companies/{cid}/employees/{eid}` documents and geofences/shifts/leaveTypes. +3. Create the Firebase Auth user and set custom claims + `{ cid, eid, r: ["EMPLOYEE"], b: [branchIds] }` (Admin SDK). +4. Sign in from the app — session bootstraps via `GET /v1/me`, then full sync runs. + +## Distribution + +| Platform | Status | Link | +|---|---|---| +| **Google Play** | Closed testing | — | +| **App Store** | In review | — | +| **Web portal** | Live | `worktrack-prod.web.app` | + +## Roadmap + +See `docs/09-roadmap.md` for the full plan. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..449ad07 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,264 @@ +import java.util.Properties + +plugins { + alias(libs.plugins.worktrack.android.application) + alias(libs.plugins.worktrack.android.hilt) +} + +// ---------------------------------------------------------------- signing +// +// The release signing key is the one artefact in this project that cannot be +// replaced: lose it and this app can never be updated again; leak it and +// someone else can sign a build as us. So nothing about it lives in the repo. +// +// Values come from keystore.properties at the repo root (gitignored), or from +// Gradle properties so CI can inject them without a file. Create the key once: +// +// keytool -genkeypair -v -keystore worktrack-release.jks \ +// -alias worktrack -keyalg RSA -keysize 4096 -validity 10000 +// +// then copy keystore.properties.example to keystore.properties and fill it in. +// Back the .jks up somewhere you will still have in five years. +val keystorePropertiesFile = rootProject.file("keystore.properties") +val keystoreProperties = Properties().apply { + if (keystorePropertiesFile.exists()) { + keystorePropertiesFile.inputStream().use { load(it) } + } +} + +fun signingValue(fileKey: String, gradleProperty: String): String? = + keystoreProperties.getProperty(fileKey) ?: project.findProperty(gradleProperty) as String? + +val releaseStorePath = signingValue("storeFile", "worktrack.storeFile") +val releaseStoreFile = releaseStorePath?.let(rootProject::file) +val hasReleaseKey = releaseStoreFile?.exists() == true + +// Signing a release with the DEBUG key, on purpose, to smoke-test that R8 has +// not stripped something the app needs at runtime. An APK signed this way must +// never reach a user, so it takes an explicit flag: +// ./gradlew :app:assembleRelease -Pworktrack.debugSignRelease +val debugSignRelease = project.hasProperty("worktrack.debugSignRelease") + +// API endpoints per environment, kept together so it is obvious at a glance +// which build talks to which backend. +val productionApiBaseUrl = "https://worktrack-prod.web.app/v1/" + +// 10.0.2.2 is the host machine's loopback as seen from an Android emulator. +val emulatorApiBaseUrl = "http://10.0.2.2:5001/demo-worktrack/us-central1/api/v1/" + +// A debug build talks to the LOCAL emulator unless told otherwise, so everyday +// development cannot read or write a real company's data by accident. To point +// a debug build somewhere else on purpose: +// ./gradlew :app:assembleDebug -Pworktrack.apiBaseUrl=https://worktrack-prod.web.app/v1/ +// or persist it in gradle.properties (project or ~/.gradle/): +// worktrack.apiBaseUrl=https://worktrack-prod.web.app/v1/ +val debugApiBaseUrl = + (project.findProperty("worktrack.apiBaseUrl") as String?) ?: emulatorApiBaseUrl + +android { + namespace = "app.worktrack" + + defaultConfig { + applicationId = "app.worktrack" + // 1.0.1 carries the location-permission fix: 1.0.0 asked for + // ACCESS_FINE_LOCATION alone, which Android 12 and newer drop outright + // from an app targeting SDK 31+, so GPS check-in could never get + // permission. Do not ship 1.0.0 to anyone. + // + // 1.1.0 adds work assignment: the dashboard tells an employee which + // part of the job they are on today and on their next working day. + // + // Bumping this is not bookkeeping. The download page publishes a + // SHA-256 per file and tells customers to check it before installing, + // and the filenames carry the version. Shipping different bytes as + // "worktrack-1.0.1-arm64.apk" would change the published checksum under + // an unchanged name, so somebody who verified yesterday's download + // would find a mismatch today and rightly conclude it had been + // tampered with. It would also leave two different apps both answering + // "1.0.1" when support asks which version somebody is running. + // 4 / 1.2.0 because the 1.1.0 APKs on the download page were built + // against targetSdk 35 with AGP 8.5.2, and this one is targetSdk 36 on + // AGP 8.13. Same version number on two materially different binaries + // makes a bug report impossible to place. + versionCode = 4 + versionName = "1.2.0" + + // Inherited by the release build: Firebase Hosting rewrites /v1/** to + // the `api` Cloud Function (see backend/firebase.json), a stable URL + // that matches the web portal. Swap for a custom domain (e.g. + // worktrack.af) once you connect one in Hosting. The debug build type + // overrides both of these below. + buildConfigField("String", "API_BASE_URL", "\"$productionApiBaseUrl\"") + buildConfigField("boolean", "USE_EMULATORS", "false") + } + + signingConfigs { + if (hasReleaseKey) { + create("release") { + storeFile = releaseStoreFile + storePassword = signingValue("storePassword", "worktrack.storePassword") + keyAlias = signingValue("keyAlias", "worktrack.keyAlias") + keyPassword = signingValue("keyPassword", "worktrack.keyPassword") + + // v1 (JAR signing) is for Android 6 and older; minSdk is 26, so + // it only adds size and build time. v3 carries the proof needed + // to rotate to a new signing key later without every user having + // to reinstall — cheap now, impossible to add retroactively. + enableV1Signing = false + enableV2Signing = true + enableV3Signing = true + } + } + } + + buildTypes { + /* + * The public demo build. + * + * A separate applicationId is the whole point: without it, installing + * the demo would REPLACE the real app on the phone of anyone who + * already runs WorkTrack, taking their queued offline punches with it. + * With the suffix the two sit side by side. + * + * It is a build type rather than a product flavor deliberately — a + * flavor renames every existing variant task (compileDebugKotlin + * becomes compileProductionDebugKotlin), which would break CI and every + * command in the docs. A build type only adds `assembleDemo`. + * + * Firebase config comes from app/src/demo/google-services.json, which + * points at worktrack-demo-af, so Auth and the API agree about which + * backend they are talking to. + */ + create("demo") { + initWith(getByName("release")) + applicationIdSuffix = ".demo" + versionNameSuffix = "-demo" + // Library modules only define debug/release; without this the demo + // build type has nothing to resolve against in them. + matchingFallbacks += listOf("release") + + buildConfigField("String", "API_BASE_URL", "\"https://demo.linumic.com/v1/\"") + buildConfigField("boolean", "USE_EMULATORS", "false") + + signingConfig = when { + hasReleaseKey -> signingConfigs.getByName("release") + debugSignRelease -> signingConfigs.getByName("debug") + else -> null + } + } + + release { + // R8 and resource shrinking are already on from the convention + // plugin; this only decides what the output gets signed with. + signingConfig = when { + hasReleaseKey -> signingConfigs.getByName("release") + debugSignRelease -> signingConfigs.getByName("debug") + else -> null + } + } + + debug { + // Debug used to point at the LIVE worktrack-prod backend, so anyone + // who installed a development build was writing to a real company's + // attendance and payroll. It now defaults to the local emulator; + // see debugApiBaseUrl above for the deliberate override. + // + // The applicationId stays "app.worktrack" (no suffix) so it keeps + // matching the client in google-services.json. Cleartext to the + // emulator is already permitted by the debug source set — see + // app/src/debug/res/xml/network_security_config.xml. + buildConfigField("String", "API_BASE_URL", "\"$debugApiBaseUrl\"") + + // Firebase Auth has to follow the API: authenticating against + // production while calling the emulator (or the reverse) issues + // tokens the other side cannot verify. + buildConfigField( + "boolean", + "USE_EMULATORS", + (debugApiBaseUrl == emulatorApiBaseUrl).toString(), + ) + } + } + + buildFeatures { + buildConfig = true + } + + /* + * One APK per CPU architecture instead of one carrying all four. + * + * The TensorFlow Lite and ML Kit native libraries dominate the download, + * and a universal APK ships every architecture to every phone: roughly + * 40 MB of x86 that only an emulator will ever load. On the connections + * this app is installed over, that is the difference between a download + * that finishes and one that does not. + * + * The universal APK is still produced, for the emulator and as a fallback + * when the target device is unknown. + */ + // An App Bundle splits by ABI itself, on Google's servers, so AGP refuses + // to build one while these splits are also on — it fails in + // buildReleasePreBundle with "Multiple shrunk-resources files found", + // which names a symptom and not the cause. + // + // We still need the three APKs: customers install by sideload from the + // download page, and the small per-ABI files matter on Afghan connections. + // So the splits stay on for every build EXCEPT a bundle. + // `contains`, not `startsWith`: the task that actually trips over this is + // `buildReleasePreBundle`, which begins with "build". Matching only the + // leading word looks right and silently does nothing. + val buildingBundle = gradle.startParameter.taskNames.any { + it.contains("bundle", ignoreCase = true) + } + + splits { + abi { + isEnable = !buildingBundle + reset() + include("armeabi-v7a", "arm64-v8a", "x86_64") + isUniversalApk = true + } + } +} + +dependencies { + implementation(projects.feature.auth) + implementation(projects.feature.dashboard) + implementation(projects.feature.attendance) + implementation(projects.feature.leave) + implementation(projects.feature.payslips) + implementation(projects.feature.profile) + + implementation(projects.core.common) + implementation(projects.core.model) + implementation(projects.core.domain) + implementation(projects.core.data) + implementation(projects.core.sync) + implementation(projects.core.network) + implementation(projects.core.designsystem) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.biometric) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.hilt.navigation.compose) + implementation(libs.androidx.compose.material.icons) + + implementation(libs.androidx.work.runtime) + implementation(libs.hilt.ext.work) + ksp(libs.hilt.ext.compiler) + + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.auth) + + androidTestImplementation(libs.androidx.test.ext) + androidTestImplementation(libs.androidx.test.runner) +} + +// google-services.json is environment-specific and never committed; the plugin +// is applied only when the file is present so CI and fresh clones still build. +if (file("google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") +} diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 0000000..9c41ef7 --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..88bfdcd --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,20 @@ +# kotlinx.serialization: keep generated serializer lookups. +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt +-keepclassmembers class kotlinx.serialization.json.** { *** Companion; } +-keepclasseswithmembers class kotlinx.serialization.json.** { kotlinx.serialization.KSerializer serializer(...); } +-keep,includedescriptorclasses class app.worktrack.**$$serializer { *; } +-keepclassmembers class app.worktrack.** { *** Companion; } +-keepclasseswithmembers class app.worktrack.** { kotlinx.serialization.KSerializer serializer(...); } + +# Retrofit reflects on interface method generics. +-keepattributes Signature, Exceptions +-keep,allowobfuscation,allowshrinking interface retrofit2.Call +-keep,allowobfuscation,allowshrinking class retrofit2.Response +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation + +# OkHttp platform hooks (harmless on Android). +-dontwarn okhttp3.internal.platform.** +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..23cbd1b --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/debug/res/xml/network_security_config.xml b/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..9f1e95f --- /dev/null +++ b/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,11 @@ + + + + + 10.0.2.2 + localhost + 127.0.0.1 + + diff --git a/app/src/demo/res/values-en/strings.xml b/app/src/demo/res/values-en/strings.xml new file mode 100644 index 0000000..0faac31 --- /dev/null +++ b/app/src/demo/res/values-en/strings.xml @@ -0,0 +1,4 @@ + + + WorkTrack Demo + diff --git a/app/src/demo/res/values-ps/strings.xml b/app/src/demo/res/values-ps/strings.xml new file mode 100644 index 0000000..1b52888 --- /dev/null +++ b/app/src/demo/res/values-ps/strings.xml @@ -0,0 +1,4 @@ + + + د WorkTrack ډیمو + diff --git a/app/src/demo/res/values/strings.xml b/app/src/demo/res/values/strings.xml new file mode 100644 index 0000000..7f00e35 --- /dev/null +++ b/app/src/demo/res/values/strings.xml @@ -0,0 +1,9 @@ + + + + دموی WorkTrack + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d31fbc1 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/app/worktrack/MainActivity.kt b/app/src/main/kotlin/app/worktrack/MainActivity.kt new file mode 100644 index 0000000..8038b99 --- /dev/null +++ b/app/src/main/kotlin/app/worktrack/MainActivity.kt @@ -0,0 +1,39 @@ +package app.worktrack + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import app.worktrack.core.designsystem.theme.WorkTrackTheme +import app.worktrack.ui.WorkTrackApp +import dagger.hilt.android.AndroidEntryPoint + +// AppCompatActivity (not ComponentActivity) so AppCompatDelegate can apply the +// user's chosen app language (Dari/Pashto/English) on every API level. +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + WorkTrackTheme { + // The window itself has no themed background — the XML theme is + // AppCompat only, so Compose has to paint it. Without this, any + // screen that is not inside MainScaffold (the sign-in screen) + // shows through to AppCompat's default grey instead of the + // brand background. + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + WorkTrackApp() + } + } + } + } +} diff --git a/app/src/main/kotlin/app/worktrack/WorkTrackApplication.kt b/app/src/main/kotlin/app/worktrack/WorkTrackApplication.kt new file mode 100644 index 0000000..f43881e --- /dev/null +++ b/app/src/main/kotlin/app/worktrack/WorkTrackApplication.kt @@ -0,0 +1,53 @@ +package app.worktrack + +import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import app.worktrack.core.common.coroutines.ApplicationScope +import app.worktrack.core.domain.repository.SyncScheduler +import app.worktrack.core.domain.usecase.auth.ObserveSessionUseCase +import app.worktrack.emulator.EmulatorConfig +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@HiltAndroidApp +class WorkTrackApplication : Application(), Configuration.Provider { + + @Inject lateinit var workerFactory: HiltWorkerFactory + + @Inject lateinit var syncScheduler: SyncScheduler + + @Inject lateinit var observeSession: ObserveSessionUseCase + + @Inject @ApplicationScope lateinit var applicationScope: CoroutineScope + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + + override fun onCreate() { + super.onCreate() + + // Local demo: point Firebase Auth at the emulator before any auth call. + if (BuildConfig.USE_EMULATORS) { + EmulatorConfig.apply(this) + } + + // Whenever a session exists (fresh sign-in or app restart), make sure the + // periodic background sync is registered and kick one cycle immediately. + observeSession() + .filterNotNull() + .distinctUntilChangedBy { it.uid } + .onEach { + syncScheduler.schedulePeriodicSync() + syncScheduler.requestImmediateSync() + } + .launchIn(applicationScope) + } +} diff --git a/app/src/main/kotlin/app/worktrack/di/AppModule.kt b/app/src/main/kotlin/app/worktrack/di/AppModule.kt new file mode 100644 index 0000000..405f834 --- /dev/null +++ b/app/src/main/kotlin/app/worktrack/di/AppModule.kt @@ -0,0 +1,29 @@ +package app.worktrack.di + +import app.worktrack.BuildConfig +import app.worktrack.core.common.coroutines.ApplicationScope +import app.worktrack.core.network.di.ApiConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + @Provides + @Singleton + fun provideApiConfig(): ApiConfig = + ApiConfig(baseUrl = BuildConfig.API_BASE_URL, useEmulators = BuildConfig.USE_EMULATORS) + + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope = + CoroutineScope(SupervisorJob() + Dispatchers.Default) +} diff --git a/app/src/main/kotlin/app/worktrack/emulator/EmulatorConfig.kt b/app/src/main/kotlin/app/worktrack/emulator/EmulatorConfig.kt new file mode 100644 index 0000000..5358277 --- /dev/null +++ b/app/src/main/kotlin/app/worktrack/emulator/EmulatorConfig.kt @@ -0,0 +1,46 @@ +package app.worktrack.emulator + +import android.content.Context +import android.util.Log +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth + +/** + * Wires the app to the local Firebase Emulator Suite for the offline demo, so + * the seeded users (see backend/functions/seed.js) can sign in without a real + * Firebase project or google-services.json. + * + * Applied only in debug builds (BuildConfig.USE_EMULATORS) from + * WorkTrackApplication.onCreate, before any Firebase Auth usage. + */ +object EmulatorConfig { + + private const val TAG = "EmulatorConfig" + + // 10.0.2.2 is the host machine's loopback as seen from the Android emulator. + private const val EMULATOR_HOST = "10.0.2.2" + private const val AUTH_EMULATOR_PORT = 9099 + + fun apply(context: Context) { + // Without google-services.json the default FirebaseApp never auto-inits, + // so create it here with demo options (any values are accepted by the + // Auth emulator; the project id must match the emulator's project). + if (FirebaseApp.getApps(context).isEmpty()) { + FirebaseApp.initializeApp( + context, + FirebaseOptions.Builder() + .setProjectId("demo-worktrack") + .setApplicationId("1:1234567890:android:demoworktrack") + .setApiKey("demo-key") + .build(), + ) + } + + // Route Auth to the emulator. Safe to call once before any auth call; + // guarded so a process relaunch (or double init) doesn't crash. + runCatching { + FirebaseAuth.getInstance().useEmulator(EMULATOR_HOST, AUTH_EMULATOR_PORT) + }.onFailure { Log.w(TAG, "Auth emulator already configured: ${it.message}") } + } +} diff --git a/app/src/main/kotlin/app/worktrack/ui/BiometricLockScreen.kt b/app/src/main/kotlin/app/worktrack/ui/BiometricLockScreen.kt new file mode 100644 index 0000000..8b970fb --- /dev/null +++ b/app/src/main/kotlin/app/worktrack/ui/BiometricLockScreen.kt @@ -0,0 +1,100 @@ +package app.worktrack.ui + +import android.content.Context +import android.content.ContextWrapper +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricPrompt +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import app.worktrack.R +import app.worktrack.core.designsystem.component.WtPrimaryButton + +/** + * Full-screen lock shown over the signed-in app when the biometric lock is on. + * The Firebase session stays valid underneath; this only gates local access, so + * the prompt fires automatically and can be retried, but never leaks the app. + */ +@Composable +fun BiometricLockScreen(onUnlock: () -> Unit) { + val context = LocalContext.current + val activity = remember(context) { context.findFragmentActivity() } + val title = stringResource(R.string.biometric_lock_title) + val subtitle = stringResource(R.string.biometric_lock_subtitle) + val negative = stringResource(R.string.biometric_lock_cancel) + + fun authenticate() { + val act = activity ?: return + val prompt = BiometricPrompt( + act, + ContextCompat.getMainExecutor(act), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + onUnlock() + } + }, + ) + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setAllowedAuthenticators(BIOMETRIC_STRONG or BIOMETRIC_WEAK) + .setNegativeButtonText(negative) + .build() + prompt.authenticate(info) + } + + LaunchedEffect(Unit) { authenticate() } + + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(56.dp), + ) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + WtPrimaryButton( + text = stringResource(R.string.biometric_lock_unlock), + onClick = { authenticate() }, + ) + } + } +} + +private fun Context.findFragmentActivity(): FragmentActivity? { + var ctx: Context? = this + while (ctx is ContextWrapper) { + if (ctx is FragmentActivity) return ctx + ctx = ctx.baseContext + } + return null +} diff --git a/app/src/main/kotlin/app/worktrack/ui/MainScaffold.kt b/app/src/main/kotlin/app/worktrack/ui/MainScaffold.kt new file mode 100644 index 0000000..82c304c --- /dev/null +++ b/app/src/main/kotlin/app/worktrack/ui/MainScaffold.kt @@ -0,0 +1,132 @@ +package app.worktrack.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BeachAccess +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.outlined.BeachAccess +import androidx.compose.material.icons.outlined.Fingerprint +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import app.worktrack.R +import app.worktrack.core.model.CompanyFeatures +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import app.worktrack.feature.attendance.navigation.PUNCH_ROUTE +import app.worktrack.feature.attendance.navigation.ATTENDANCE_HISTORY_ROUTE +import app.worktrack.feature.attendance.navigation.attendanceScreens +import app.worktrack.feature.dashboard.navigation.DASHBOARD_ROUTE +import app.worktrack.feature.dashboard.navigation.dashboardScreen +import app.worktrack.feature.leave.navigation.LEAVE_ROUTE +import app.worktrack.feature.leave.navigation.leaveScreens +import app.worktrack.feature.payslips.navigation.PAYSLIPS_ROUTE +import app.worktrack.feature.payslips.navigation.payslipScreens +import app.worktrack.feature.profile.navigation.PROFILE_ROUTE +import app.worktrack.feature.profile.navigation.profileScreen + +private data class TopLevelDestination( + val route: String, + val labelRes: Int, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, + /** Whether the company has this module enabled. */ + val enabled: (CompanyFeatures) -> Boolean = { true }, +) + +private val topLevelDestinations = listOf( + TopLevelDestination(DASHBOARD_ROUTE, R.string.nav_home, Icons.Filled.Home, Icons.Outlined.Home), + TopLevelDestination(PUNCH_ROUTE, R.string.nav_attendance, Icons.Filled.Fingerprint, Icons.Outlined.Fingerprint), + TopLevelDestination( + LEAVE_ROUTE, R.string.nav_leave, Icons.Filled.BeachAccess, Icons.Outlined.BeachAccess, + enabled = { it.leave }, + ), + TopLevelDestination(PROFILE_ROUTE, R.string.nav_profile, Icons.Filled.Person, Icons.Outlined.Person), +) + +@Composable +fun MainScaffold(features: CompanyFeatures) { + val navController = rememberNavController() + val backStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = backStackEntry?.destination + + // Hide modules the company has switched off (mirrors the web portal). + val visibleDestinations = topLevelDestinations.filter { it.enabled(features) } + val showBottomBar = currentDestination?.route in topLevelDestinations.map { it.route } + + Scaffold( + bottomBar = { + if (showBottomBar) { + NavigationBar { + visibleDestinations.forEach { destination -> + val selected = currentDestination + ?.hierarchy + ?.any { it.route == destination.route } == true + val label = stringResource(destination.labelRes) + NavigationBarItem( + selected = selected, + onClick = { + navController.navigate(destination.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { + Icon( + imageVector = if (selected) { + destination.selectedIcon + } else { + destination.unselectedIcon + }, + contentDescription = label, + ) + }, + label = { Text(label) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.onTertiaryContainer, + indicatorColor = MaterialTheme.colorScheme.tertiaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onSurface, + ), + ) + } + } + } + }, + ) { padding -> + NavHost( + navController = navController, + startDestination = DASHBOARD_ROUTE, + modifier = Modifier.padding(padding), + ) { + dashboardScreen( + onPunchClick = { navController.navigate(PUNCH_ROUTE) }, + onAttendanceHistoryClick = { navController.navigate(ATTENDANCE_HISTORY_ROUTE) }, + ) + attendanceScreens(navController) + leaveScreens(navController) + payslipScreens(navController) + profileScreen( + onPayslipsClick = { navController.navigate(PAYSLIPS_ROUTE) }, + ) + } + } +} diff --git a/app/src/main/kotlin/app/worktrack/ui/MainViewModel.kt b/app/src/main/kotlin/app/worktrack/ui/MainViewModel.kt new file mode 100644 index 0000000..9e9abaf --- /dev/null +++ b/app/src/main/kotlin/app/worktrack/ui/MainViewModel.kt @@ -0,0 +1,56 @@ +package app.worktrack.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.domain.usecase.auth.ObserveBiometricLockUseCase +import app.worktrack.core.domain.usecase.auth.ObserveSessionUseCase +import app.worktrack.core.model.UserSession +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** Root auth state: Loading until the persisted session has been read once. */ +sealed interface RootUiState { + data object Loading : RootUiState + data object SignedOut : RootUiState + data class SignedIn(val session: UserSession) : RootUiState +} + +@HiltViewModel +class MainViewModel @Inject constructor( + observeSession: ObserveSessionUseCase, + observeBiometricLock: ObserveBiometricLockUseCase, +) : ViewModel() { + + val uiState: StateFlow = observeSession() + .map { session -> + if (session == null) RootUiState.SignedOut else RootUiState.SignedIn(session) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = RootUiState.Loading, + ) + + // In-memory: reset to false on every cold start, so a fresh app launch with + // a persisted session must pass the biometric prompt again. + private val unlocked = MutableStateFlow(false) + + /** True when a signed-in user must clear the biometric lock before continuing. */ + val locked: StateFlow = combine(observeBiometricLock(), unlocked) { enabled, unlocked -> + enabled && !unlocked + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = false, + ) + + fun onUnlocked() { + unlocked.value = true + } +} diff --git a/app/src/main/kotlin/app/worktrack/ui/WorkTrackApp.kt b/app/src/main/kotlin/app/worktrack/ui/WorkTrackApp.kt new file mode 100644 index 0000000..f668924 --- /dev/null +++ b/app/src/main/kotlin/app/worktrack/ui/WorkTrackApp.kt @@ -0,0 +1,42 @@ +package app.worktrack.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import app.worktrack.core.designsystem.component.FullScreenLoading +import app.worktrack.feature.auth.navigation.AUTH_GRAPH_ROUTE +import app.worktrack.feature.auth.navigation.authGraph + +/** + * Root switch between the auth and main experiences. Each state owns its own + * NavHost, so signing out atomically drops the entire main back stack (no + * stale tenant data can be navigated back to). + */ +@Composable +fun WorkTrackApp(viewModel: MainViewModel = hiltViewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + when (state) { + RootUiState.Loading -> FullScreenLoading(Modifier) + + RootUiState.SignedOut -> { + val navController = rememberNavController() + NavHost(navController = navController, startDestination = AUTH_GRAPH_ROUTE) { + authGraph() + } + } + + is RootUiState.SignedIn -> { + val locked by viewModel.locked.collectAsStateWithLifecycle() + if (locked) { + BiometricLockScreen(onUnlock = viewModel::onUnlocked) + } else { + MainScaffold((state as RootUiState.SignedIn).session.features) + } + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..1d8db23 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..412e6c1 --- /dev/null +++ b/app/src/main/res/values-en/strings.xml @@ -0,0 +1,12 @@ + + + WorkTrack + Home + Attendance + Leave + Profile + Locked + Verify your fingerprint to open the app + Unlock + Cancel + diff --git a/app/src/main/res/values-ps/strings.xml b/app/src/main/res/values-ps/strings.xml new file mode 100644 index 0000000..05a5eb0 --- /dev/null +++ b/app/src/main/res/values-ps/strings.xml @@ -0,0 +1,12 @@ + + + WorkTrack + کور + حاضري + رخصتي + پروفایل + بند دی + د اپ خلاصولو لپاره خپل ګوته تصدیق کړئ + خلاصول + لغوه + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..3407532 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #006874 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..dc23590 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + + WorkTrack + خانه + حاضری + رخصتی + پروفایل + قفل امنیتی + برای ورود به برنامه اثر انگشت خود را تأیید کنید + باز کردن قفل + لغو + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..c991c5b --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..3b30a4b --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..032ca23 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 0000000..62d0fb7 --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/backend/.firebaserc.example b/backend/.firebaserc.example new file mode 100644 index 0000000..46f292b --- /dev/null +++ b/backend/.firebaserc.example @@ -0,0 +1,6 @@ +{ + "projects": { + "default": "worktrack-dev", + "prod": "worktrack-prod" + } +} diff --git a/backend/firestore.indexes.json b/backend/firestore.indexes.json new file mode 100644 index 0000000..4525384 --- /dev/null +++ b/backend/firestore.indexes.json @@ -0,0 +1,353 @@ +{ + "indexes": [ + { + "collectionGroup": "punches", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "employeeId", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "punches", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "employeeId", + "order": "ASCENDING" + }, + { + "fieldPath": "punchedAt", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "punches", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "employeeId", + "order": "ASCENDING" + }, + { + "fieldPath": "punchedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "attendanceDays", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "employeeId", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "attendanceDays", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "employeeId", + "order": "ASCENDING" + }, + { + "fieldPath": "date", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "leaveRequests", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "employeeId", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "leaveRequests", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "currentApproverId", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "leaveBalances", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "employeeId", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "payslips", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "employeeId", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "payslips", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "employeeId", + "order": "ASCENDING" + }, + { + "fieldPath": "periodYear", + "order": "ASCENDING" + }, + { + "fieldPath": "periodMonth", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "shiftAssignments", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "employeeId", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "employees", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "branchId", + "order": "ASCENDING" + }, + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "employees", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "regularizations", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "employeeId", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "regularizations", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "currentApproverId", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "tasks", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "assigneeIds", + "arrayConfig": "CONTAINS" + }, + { + "fieldPath": "updatedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "tasks", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "assigneeIds", + "arrayConfig": "CONTAINS" + }, + { + "fieldPath": "endDate", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "tasks", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "projectId", + "order": "ASCENDING" + }, + { + "fieldPath": "endDate", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "tasks", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "projectId", + "order": "ASCENDING" + }, + { + "fieldPath": "assigneeIds", + "arrayConfig": "CONTAINS" + }, + { + "fieldPath": "endDate", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "attendanceDays", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "employeeId", + "order": "ASCENDING" + }, + { + "fieldPath": "date", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "advances", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "employeeId", + "order": "ASCENDING" + }, + { + "fieldPath": "issuedOn", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "pieceRecords", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "employeeId", + "order": "ASCENDING" + }, + { + "fieldPath": "date", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "notifications", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "employeeId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "notifications", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "employeeId", + "order": "ASCENDING" + }, + { + "fieldPath": "readAt", + "order": "ASCENDING" + } + ] + } + ], + "fieldOverrides": [] +} diff --git a/backend/firestore.rules b/backend/firestore.rules new file mode 100644 index 0000000..f8ac463 --- /dev/null +++ b/backend/firestore.rules @@ -0,0 +1,12 @@ +rules_version = '2'; + +// WorkTrack: ALL reads and writes go through the REST API (Admin SDK), which +// enforces tenant isolation and RBAC. Client SDKs have no direct Firestore +// access — these rules are the defense-in-depth backstop, not the auth layer. +service cloud.firestore { + match /databases/{database}/documents { + match /{document=**} { + allow read, write: if false; + } + } +} diff --git a/backend/functions/package-lock.json b/backend/functions/package-lock.json new file mode 100644 index 0000000..268d890 --- /dev/null +++ b/backend/functions/package-lock.json @@ -0,0 +1,4115 @@ +{ + "name": "worktrack-functions", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "worktrack-functions", + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.5", + "express": "^4.19.2", + "firebase-admin": "^12.1.0", + "firebase-functions": "^5.0.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "typescript": "^5.5.4", + "vitest": "^4.1.10" + }, + "engines": { + "node": "20" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.2.tgz", + "integrity": "sha512-LMs47Vinv2HBMZi49C09dJxp0QT5LwDzFaVGf/+ITHe3BlIhUiLNttkATSXplc89A2lAaeTqjgqVkiRfUGyQiQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.2.tgz", + "integrity": "sha512-oMEZ1TDlBz479lmABwWsWjzHwheQKiAgnuKxE0pz0IXCVx7/rtlkx1fQ6GfgK24WCrxDKMplZrT50Kh04iMbXQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.3.tgz", + "integrity": "sha512-Fc9wuJGgxoxQeavybiuwgyi+0rssr76b+nHpj+eGhXFYAdudMWyfBHvFL/I5fEHniUM/UQdFzi9VXJK2iZF7FQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.9.tgz", + "integrity": "sha512-gm8EUEJE/fEac86AvHn8Z/QW8BvR56TBw3hMW0O838J/1mThYQXAIQBgUv75EqlCZfdawpWLrKt1uXvp9ciK3Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.8.tgz", + "integrity": "sha512-dzXALZeBI1U5TXt6619cv0+tgEhJiwlUtQ55WNZY7vGAjv7Q1QioV969iYwt1AQQ0ovHnEW0YW9TiBfefLvErg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.2", + "@firebase/auth-interop-types": "0.2.3", + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-1.0.8.tgz", + "integrity": "sha512-OpeWZoPE3sGIRPBKYnW9wLad25RaWbGyk7fFQe4xnJQKRzlynWeFBSRRAoLE2Old01WXwskUiucNqUUVlFsceg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/database": "1.0.8", + "@firebase/database-types": "1.0.5", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.5.tgz", + "integrity": "sha512-fTlqCNwFYyq/C6W7AJ5OCuq5CeZuBEsEwptnVxlNPkWCo5cTTyukzAHRSO/jaQcItz33FfYrrFk1SJofcu2AaQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.2", + "@firebase/util": "1.10.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.2.tgz", + "integrity": "sha512-Q1VuA5M1Gjqrwom6I6NUU4lQXdo9IAQieXlujeHZWvRt1b7qQ0KwBaNAjgxG27jgF9/mUwsNmO8ptBCGVYhB0A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.10.0.tgz", + "integrity": "sha512-xKtx4A668icQqoANRxyDLBLz51TAbDP9KRfpbKGxiCAW346d0BeJe5vN6/hKxxmWwnZ0mautyv39JxviwwQMOQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.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.21.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.21.0.tgz", + "integrity": "sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA==", + "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" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "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.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "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/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "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/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "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" + }, + "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" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "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" + }, + "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" + }, + "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" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "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/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "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/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "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": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "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/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "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/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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", + "optional": true, + "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/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "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/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "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==", + "license": "MIT", + "optional": true + }, + "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", + "optional": true + }, + "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", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "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.15.1", + "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/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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==", + "license": "MIT", + "optional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "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/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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==", + "license": "MIT", + "optional": true, + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "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-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "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==", + "license": "MIT", + "optional": true, + "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/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "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/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "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.15.1", + "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", + "optional": true + }, + "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", + "optional": true + }, + "node_modules/fast-xml-builder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" + }, + "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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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": "12.7.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-12.7.0.tgz", + "integrity": "sha512-raFIrOyTqREbyXsNkSHyciQLfv8AUZazehPaQS1lZBSCDYW74FYXU0nQZa3qHI4K+hawohlDbywZ4+qce9YNxA==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "1.0.8", + "@firebase/database-types": "1.0.5", + "@types/node": "^22.0.1", + "farmhash-modern": "^1.1.0", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.7.0", + "@google-cloud/storage": "^7.7.0" + } + }, + "node_modules/firebase-admin/node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/firebase-admin/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/firebase-functions": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-5.1.1.tgz", + "integrity": "sha512-KkyKZE98Leg/C73oRyuUYox04PQeeBThdygMfeX+7t1cmKWYKa/ZieYa89U8GHgED+0mF7m7wfNZOfbURYxIKg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.5", + "@types/express": "4.17.3", + "cors": "^2.8.5", + "express": "^4.17.1", + "protobufjs": "^7.2.2" + }, + "bin": { + "firebase-functions": "lib/bin/firebase-functions.js" + }, + "engines": { + "node": ">=14.10.0" + }, + "peerDependencies": { + "firebase-admin": "^11.10.0 || ^12.0.0" + } + }, + "node_modules/firebase-functions/node_modules/@types/express": { + "version": "4.17.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.3.tgz", + "integrity": "sha512-I8cGRJj3pyOLs/HndoP+25vOqhqWkAZsWMEmq1qXy/b/M3ppufecUwaK2/TVDVxcV61/iSdhykUjQQ2DLSrTdg==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "*", + "@types/serve-static": "*" + } + }, + "node_modules/form-data": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", + "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "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": "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/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", + "optional": true, + "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", + "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": { + "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==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "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", + "optional": true, + "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==", + "license": "MIT", + "optional": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "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", + "optional": true, + "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", + "optional": true, + "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", + "optional": true + }, + "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", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "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", + "optional": true, + "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/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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" + }, + "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/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "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", + "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.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.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/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.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==", + "license": "ISC", + "optional": true, + "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-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "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.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/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/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "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/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.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "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.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "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.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "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/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "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.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "anynum": "^1.0.1" + } + }, + "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/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==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "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": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/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/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "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.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "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/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "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/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "optional": true + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16.0.0" + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "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/functions/package.json b/backend/functions/package.json new file mode 100644 index 0000000..a0bf4aa --- /dev/null +++ b/backend/functions/package.json @@ -0,0 +1,33 @@ +{ + "name": "worktrack-functions", + "version": "1.0.0", + "private": true, + "description": "WorkTrack REST API v1 on Cloud Functions", + "engines": { + "node": "22" + }, + "main": "lib/index.js", + "scripts": { + "build": "tsc", + "watch": "tsc --watch", + "serve": "npm run build && firebase emulators:start --config ../../firebase.json --project demo-worktrack --only functions,firestore,auth", + "seed": "npm run build && node seed.js", + "deploy": "firebase deploy --only functions", + "typecheck": "tsc --noEmit", + "test:integration": "firebase emulators:exec --only firestore,auth --project demo-worktrack \"vitest run\"", + "test": "vitest run" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.19.2", + "firebase-admin": "^12.1.0", + "firebase-functions": "^5.0.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "typescript": "^5.5.4", + "vitest": "^4.1.10" + } +} diff --git a/backend/functions/seed.js b/backend/functions/seed.js new file mode 100644 index 0000000..4904fbe --- /dev/null +++ b/backend/functions/seed.js @@ -0,0 +1,915 @@ +/* + * Local demo seed for the Firebase Emulator Suite. + * + * Populates the Firestore + Auth emulators with a sample Afghan tenant so the + * web manager portal and the Android app show real data. No real Firebase + * project or billing is required — everything runs locally. + * + * Run (emulators must be started first): + * npm run seed + * + * Logins it creates (password for all: Passw0rd!): + * admin@worktrack.af — COMPANY_ADMIN (use this in the web portal) + * hr@worktrack.af — HR_ADMIN + * ahmad@worktrack.af — EMPLOYEE (use this in the Android app) + */ + +const { getApps, initializeApp } = require("firebase-admin/app"); +const { getFirestore, Timestamp } = require("firebase-admin/firestore"); +const { getAuth } = require("firebase-admin/auth"); + +// ------------------------------------------------------------------ target +// +// By default this writes to the local emulators. A hosted demo — the public +// "try it" tenant on the website — needs the same data in a real Firebase +// project, so `--target ` points the Admin SDK at one. +// +// Two guards stand in front of that, because this script creates logins whose +// password is published on the website and writes several hundred documents: +// +// 1. A project id that looks like production is refused outright. No flag +// overrides it. Seeding a published password into a tenant that holds real +// employees' attendance and pay is not a mistake worth leaving available. +// 2. Any real project additionally needs --yes-write-real-data, so it cannot +// happen from a half-remembered command. +// True only when this file is run as a script. When the reset scheduler +// imports it inside a Cloud Function, none of the CLI bootstrap below applies: +// the environment is already the demo project and the app is already +// initialised, so touching either would be wrong. +const IS_CLI = require.main === module; + +const argv = IS_CLI ? process.argv.slice(2) : []; +function flagValue(name) { + const i = argv.indexOf(name); + return i === -1 ? null : argv[i + 1] ?? null; +} + +const target = flagValue("--target"); +const PASSWORD = flagValue("--password") || "Passw0rd!"; +const CID = "comp_kabul"; + +/** Anything that reads as a live tenant. Matched case-insensitively. */ +const LOOKS_LIVE = /prod|production|live/i; + +if (target && LOOKS_LIVE.test(target)) { + console.error( + `\nRefusing to seed "${target}".\n\n` + + "This script creates accounts with a password that is published on the\n" + + "website, and that must never exist in a tenant with real people in it.\n" + + "Point it at a separate demo project instead.\n", + ); + process.exit(1); +} + +if (target && !argv.includes("--yes-write-real-data")) { + console.error( + `\nAbout to write demo data into the real project "${target}".\n\n` + + "This creates a company, its people, several months of attendance, and\n" + + "logins with a well-known password. Re-run with --yes-write-real-data if\n" + + "that is what you want.\n", + ); + process.exit(1); +} + +if (IS_CLI) { + if (target) { + // The Admin SDK talks to the real backend only when these are absent. + delete process.env.FIRESTORE_EMULATOR_HOST; + delete process.env.FIREBASE_AUTH_EMULATOR_HOST; + } else { + process.env.FIRESTORE_EMULATOR_HOST = + process.env.FIRESTORE_EMULATOR_HOST || "127.0.0.1:8080"; + process.env.FIREBASE_AUTH_EMULATOR_HOST = + process.env.FIREBASE_AUTH_EMULATOR_HOST || "127.0.0.1:9099"; + } +} + +const PROJECT_ID = target || process.env.GCLOUD_PROJECT || "demo-worktrack"; + +// Guarded so importing this alongside an already-initialised app (the Cloud +// Function case) does not throw or create a second one. +if (!getApps().length) { + initializeApp({ projectId: PROJECT_ID }); +} +const db = getFirestore(); +const auth = getAuth(); + +const now = Timestamp.now(); + +/** companies/{CID}/{collection} */ +function col(collection) { + return db.collection("companies").doc(CID).collection(collection); +} + +/** ISO date (YYYY-MM-DD, UTC) N days before today; 0 = today. */ +/** + * A day key in the company's own timezone. + * + * Not UTC. The nightly reset fires at 03:30 Asia/Kabul, which is 23:00 UTC the + * previous day, so a UTC day key is one behind what payroll calls today — and + * payroll charges a working day with no attendance record as unexcused + * absence. Seeding in UTC put a phantom absence on every demo payslip for the + * four and a half hours a day the two calendars disagree. + */ +const SEED_TZ = "Asia/Kabul"; +const dayFormatter = new Intl.DateTimeFormat("en-CA", { + timeZone: SEED_TZ, + year: "numeric", + month: "2-digit", + day: "2-digit", +}); + +function isoDaysAgo(n) { + return dayFormatter.format(new Date(Date.now() - n * 86_400_000)); +} + +/** Timestamp at HH:mm UTC on an ISO date. */ +function at(iso, hh, mm) { + return Timestamp.fromDate(new Date(`${iso}T${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}:00Z`)); +} + +/** Compact Gregorian -> Solar Hijri (year, month) for the current payroll period. */ +function gregToShamsi(date) { + const div = (a, b) => Math.trunc(a / b); + const B = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178]; + function jalCal(jy) { + const gy = jy + 621; let leapJ = -14, jp = B[0], jump = 0; + for (let i = 1; i < B.length; i++) { const jm = B[i]; jump = jm - jp; if (jy < jm) break; leapJ += div(jump, 33) * 8 + div(jump % 33, 4); jp = jm; } + let n = jy - jp; leapJ += div(n, 33) * 8 + div((n % 33) + 3, 4); if (jump % 33 === 4 && jump - n === 4) leapJ += 1; + const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150; const march = 20 + leapJ - leapG; + if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33; let leap = (((n + 1) % 33) - 1) % 4; if (leap === -1) leap = 4; + return { leap, gy, march }; + } + function g2d(gy, gm, gd) { let d = div((gy + div(gm - 8, 6) + 100100) * 1461, 4) + div(153 * ((gm + 9) % 12) + 2, 5) + gd - 34840408; d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752; return d; } + function d2g(jdn) { let j = 4 * jdn + 139361631; j += div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908; const i = div(j % 1461, 4) * 5 + 308; const gm = (div(i, 153) % 12) + 1; const gy = div(j, 1461) - 100100 + div(8 - gm, 6); return { gy, gm }; } + const jdn = g2d(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()); + const gy = d2g(jdn).gy; let jy = gy - 621; const r = jalCal(jy); const jdn1f = g2d(gy, 3, r.march); let k = jdn - jdn1f; + if (k >= 0) { if (k <= 185) return { year: jy, month: 1 + div(k, 31) }; k -= 186; } else { jy -= 1; k += 179; if (r.leap === 1) k += 1; } + return { year: jy, month: 7 + div(k, 30) }; +} + +const TODAY = isoDaysAgo(0); +const YEAR = Number(TODAY.slice(0, 4)); + +/** Placeholder check-in "selfie" avatars (SVG data URLs) for the demo overview. + * Real captures come from the employee app's camera; these just demo the UI. */ +const SELFIE_AVATARS = ["#0a8394", "#2e7d32", "#8a5a00"].map( + (c) => + "data:image/svg+xml," + + encodeURIComponent( + ``, + ), +); + +// --------------------------------------------------------------- org & people + +const company = { + name: "شرکت ساختمانی کابل", + legalName: "Kabul Construction Co. Ltd", + timezone: "Asia/Kabul", + currency: "AFN", + status: "ACTIVE", + plan: "PRO", + // Editable modules + work policies (the dedicated-admin Settings surface). + settings: { + features: { + shifts: true, + leave: true, + payroll: true, + regularization: true, + announcements: true, + geofencing: true, + qrKiosk: true, + faceRecognition: true, + finance: true, + }, + policies: { + standardDailyMinutes: 480, + weekendDays: [5], // Friday + lateGraceMinutes: 10, + overtimeEnabled: true, + }, + profile: { currency: "AFN", timezone: "Asia/Kabul" }, + }, + updatedAt: now, +}; + +// A day shift, an overnight shift, and a full 24-hour shift (site security). +const shifts = [ + { id: "sh_day", name: "شیفت روز", code: "DAY", startTime: "08:00", endTime: "16:00", breakMinutes: 60, graceInMinutes: 10, graceOutMinutes: 10, isNightShift: false }, + { id: "sh_night", name: "شیفت شب", code: "NIGHT", startTime: "20:00", endTime: "04:00", breakMinutes: 45, graceInMinutes: 15, graceOutMinutes: 15, isNightShift: true }, + { id: "sh_24", name: "شیفت ۲۴ ساعته", code: "24H", startTime: "08:00", endTime: "08:00", breakMinutes: 120, graceInMinutes: 15, graceOutMinutes: 15, isNightShift: true }, +]; + +const branches = [ + { + id: "br_main", + name: "دفتر مرکزی کابل", + code: "KBL-HQ", + address: "شهرنو، کابل", + latitude: 34.5553, + longitude: 69.2075, + radiusMeters: 250, + timezone: "Asia/Kabul", + status: "ACTIVE", + }, +]; + +const geofences = [ + { + id: "gf_main", + branchId: "br_main", + name: "دفتر مرکزی کابل", + latitude: 34.5553, + longitude: 69.2075, + radiusMeters: 250, + active: true, + }, +]; + +const departments = [ + { id: "dep_eng", name: "انجنیری", code: "ENG", branchId: "br_main" }, + { id: "dep_hr", name: "منابع بشری", code: "HR", branchId: "br_main" }, +]; + +const positions = [ + { id: "pos_mgr", title: "مدیر", code: "MGR", level: 5 }, + { id: "pos_eng", title: "انجنیر", code: "ENG", level: 3 }, +]; + +// The manager/admin is emp_admin; everyone else reports to them. +const employees = [ + { id: "emp_admin", employeeCode: "E-001", firstName: "احمد", lastName: "رحیمی", email: "admin@worktrack.af", dept: "dep_hr", pos: "pos_mgr", manager: null }, + { id: "emp_hr", employeeCode: "E-002", firstName: "زهرا", lastName: "نوری", email: "hr@worktrack.af", dept: "dep_hr", pos: "pos_mgr", manager: "emp_admin" }, + { id: "emp_finance", employeeCode: "E-008", firstName: "نجیب", lastName: "امینی", email: "finance@worktrack.af", dept: "dep_hr", pos: "pos_mgr", manager: "emp_admin" }, + { id: "emp_ahmad", employeeCode: "E-003", firstName: "احمد", lastName: "کریمی", email: "ahmad@worktrack.af", dept: "dep_eng", pos: "pos_eng", manager: "emp_admin" }, + { id: "emp_fatima", employeeCode: "E-004", firstName: "فاطمه", lastName: "احمدی", email: "fatima@worktrack.af", dept: "dep_eng", pos: "pos_eng", manager: "emp_admin" }, + { id: "emp_omar", employeeCode: "E-005", firstName: "عمر", lastName: "صدیقی", email: "omar@worktrack.af", dept: "dep_eng", pos: "pos_eng", manager: "emp_admin" }, + { id: "emp_yusuf", employeeCode: "E-006", firstName: "یوسف", lastName: "حبیبی", email: "yusuf@worktrack.af", dept: "dep_eng", pos: "pos_eng", manager: "emp_admin" }, + { id: "emp_maryam", employeeCode: "E-007", firstName: "مریم", lastName: "رستمی", email: "maryam@worktrack.af", dept: "dep_eng", pos: "pos_eng", manager: "emp_admin" }, +]; + +// Auth users -> custom claims. COMPANY_ADMIN sees everything; EMPLOYEE is the +// Android self-service login. +const authUsers = [ + { uid: "emp_admin", email: "admin@worktrack.af", name: "احمد رحیمی", roles: ["COMPANY_ADMIN"] }, + { uid: "emp_hr", email: "hr@worktrack.af", name: "زهرا نوری", roles: ["HR_ADMIN"] }, + { uid: "emp_finance", email: "finance@worktrack.af", name: "نجیب امینی", roles: ["FINANCE_ADMIN"] }, + { uid: "emp_ahmad", email: "ahmad@worktrack.af", name: "احمد کریمی", roles: ["EMPLOYEE"] }, +]; + +// -------------------------------------------------------------- leave & pay + +const leaveTypes = [ + { id: "lt_annual", name: "رخصتی سالانه", code: "ANNUAL", colorHex: "#2E7D32", isPaid: true, requiresAttachment: false }, + { id: "lt_sick", name: "رخصتی مریضی", code: "SICK", colorHex: "#B3261E", isPaid: true, requiresAttachment: false }, +]; + +// BASIC comes from each employee's EmployeeSalary; these are the shared +// earning/deduction components layered on top. +// Income tax is now computed automatically (progressive, per Afghan law) by +// services/payroll.ts, so it is no longer a manual component here. +const salaryComponents = [ + { id: "sc_transport", name: "کمک‌هزینه ترانسپورت", code: "TRANSPORT", type: "EARNING", calc: "FIXED", value: 3000, taxable: false, active: true }, + { id: "sc_food", name: "کمک‌هزینه غذا", code: "FOOD", type: "EARNING", calc: "FIXED", value: 2000, taxable: false, active: true }, + { id: "sc_pension", name: "سهم کارفرما (تقاعد)", code: "PENSION_ER", type: "EMPLOYER_COST", calc: "PERCENT_OF_BASIC", value: 5, taxable: false, active: true }, + // Individual-only, so the demo shows a component that reaches one person + // rather than the whole company. + { id: "sc_site", name: "امتیاز ساحه", code: "SITE", type: "EARNING", calc: "FIXED", value: 4000, taxable: true, scope: "INDIVIDUAL", active: true }, + // A deduction that reaches one person, so the demo shows both sides of the + // payslip being set individually rather than only the earnings side. + { id: "sc_loan", name: "قسط قرضه", code: "LOAN", type: "DEDUCTION", calc: "FIXED", value: 2500, taxable: false, scope: "INDIVIDUAL", active: true }, +]; + +/** + * Per-employee exceptions, so a visitor opening an employee sees the three + * shapes this supports rather than having to imagine them: one person on the + * site bonus nobody else gets, one on a larger transport allowance, and one + * withheld from transport altogether. + */ +const employeeComponents = [ + { employeeId: "emp_yusuf", componentId: "sc_site", value: null, active: true }, + { employeeId: "emp_ahmad", componentId: "sc_transport", value: 4500, active: true }, + { employeeId: "emp_omar", componentId: "sc_transport", value: null, active: false }, + // فاطمه is repaying a company loan; nobody else has this line. + { employeeId: "emp_fatima", componentId: "sc_loan", value: null, active: true }, +]; + +// Per-employee monthly basic salary (AFN). The manager (emp_admin) earns more. +const employeeSalaries = { + emp_admin: 45000, + emp_hr: 35000, + emp_finance: 40000, + emp_ahmad: 28000, + emp_fatima: 26000, + emp_omar: 25000, + emp_yusuf: 24000, + emp_maryam: 23000, +}; + +const announcements = [ + { + id: "ann_1", + title: "جلسهٔ عمومی کارمندان", + body: "روز یکشنبه ساعت ۱۰ صبح جلسهٔ عمومی در دفتر مرکزی برگزار می‌شود. حضور همه الزامی است.", + priority: "IMPORTANT", + createdByName: "احمد رحیمی", + }, + { + id: "ann_2", + title: "پرداخت معاش ماه", + body: "معاش این ماه تا آخر هفته به حساب‌ها واریز می‌شود.", + priority: "NORMAL", + createdByName: "زهرا نوری", + }, +]; + +// --------------------------------------------------------------------- writes + +async function seedOrg() { + await db.collection("companies").doc(CID).set(company); + + for (const b of branches) { + await col("branches").doc(b.id).set({ companyId: CID, ...b, updatedAt: now }); + } + for (const g of geofences) { + await col("geofences").doc(g.id).set({ companyId: CID, ...g, updatedAt: now }); + } + for (const s of shifts) { + await col("shifts").doc(s.id).set({ companyId: CID, ...s, active: true, updatedAt: now }); + } + // A small roster for today: two on the day shift, one on nights. + const roster = [ + { emp: "emp_ahmad", shift: "sh_day" }, + { emp: "emp_omar", shift: "sh_day" }, + { emp: "emp_yusuf", shift: "sh_night" }, + ]; + for (const r of roster) { + await col("shiftAssignments").doc(`${r.emp}_${TODAY}`).set({ + companyId: CID, + employeeId: r.emp, + shiftId: r.shift, + date: TODAY, + branchId: "br_main", + source: "ROSTER", + updatedAt: now, + }); + } + for (const d of departments) { + await col("departments").doc(d.id).set({ companyId: CID, ...d }); + } + for (const p of positions) { + await col("positions").doc(p.id).set({ companyId: CID, ...p }); + } + for (const e of employees) { + await col("employees").doc(e.id).set({ + companyId: CID, + employeeCode: e.employeeCode, + firstName: e.firstName, + lastName: e.lastName, + email: e.email, + phone: "+93 700 000 000", + avatarUrl: null, + branchId: "br_main", + departmentId: e.dept, + positionId: e.pos, + managerId: e.manager, + employmentType: "FULL_TIME", + joinDate: "2024-03-21", + status: "ACTIVE", + updatedAt: now, + }); + } +} + +async function seedAuth() { + for (const u of authUsers) { + try { + await auth.deleteUser(u.uid); + } catch { + // first run: nothing to delete + } + await auth.createUser({ + uid: u.uid, + email: u.email, + emailVerified: true, + password: PASSWORD, + displayName: u.name, + }); + await auth.setCustomUserClaims(u.uid, { + cid: CID, + eid: u.uid, + r: u.roles, + b: ["br_main"], + }); + } +} + +/** + * How far back attendance is laid down. + * + * It has to cover the whole elapsed part of the current Shamsi month, not just + * the last week. Payroll counts a working day with no attendance record as + * unexcused absence, so a visitor who presses "Run payroll" on the seeded + * month would otherwise watch everybody's pay drop by the days the seed never + * wrote — the demo's headline artifact falling apart on the first click. + * 40 days clears the longest Shamsi month with room to spare. + */ +const ATTENDANCE_DAYS = 40; + +async function seedAttendance() { + // Attendance for every employee, with realistic variety in the last week. + for (let d = ATTENDANCE_DAYS - 1; d >= 0; d--) { + const iso = isoDaysAgo(d); + const weekday = new Date(`${iso}T00:00:00Z`).getUTCDay(); // 5 = Friday + // Collected and awaited below: payroll reads this collection immediately + // after, so firing these off unawaited raced the run — and a rejected write + // would have surfaced as an unhandled rejection rather than failing here. + const writes = []; + employees.forEach((e, idx) => { + let status = "PRESENT"; + let lateMinutes = 0; + let firstInAt = at(iso, 8, 0); + let lastOutAt = at(iso, 16, 0); + let workedMinutes = 480; + + if (weekday === 5) { + status = "WEEK_OFF"; // Friday is the Afghan weekend + workedMinutes = 0; + firstInAt = null; + lastOutAt = null; + } else if (d === 0 && idx === 3) { + status = "ABSENT"; + workedMinutes = 0; + firstInAt = null; + lastOutAt = null; + } else if (d === 0 && idx === 4) { + status = "LEAVE"; + workedMinutes = 0; + firstInAt = null; + lastOutAt = null; + } else if (idx === 2 && (d === 0 || d === 2)) { + status = "PRESENT"; + lateMinutes = 25; + firstInAt = at(iso, 8, 25); + workedMinutes = 455; + } else if (d === 1 && idx === 5) { + status = "HALF_DAY"; + lastOutAt = at(iso, 12, 0); + workedMinutes = 240; + } + + // Demo check-in selfies (placeholder avatars) on a few of today's present + // days, so the manager's overview visibly shows photo-verified attendance. + const hasSelfie = d === 0 && firstInAt && idx < 3; + + writes.push(col("attendanceDays").doc(`${e.id}_${iso}`).set({ + employeeId: e.id, + date: iso, + shiftId: null, + firstInAt, + lastOutAt, + workedMinutes, + lateMinutes, + earlyOutMinutes: 0, + overtimeMinutes: 0, + status, + checkInSelfie: hasSelfie ? SELFIE_AVATARS[idx % SELFIE_AVATARS.length] : null, + computedAt: now, + updatedAt: now, + })); + }); + + await Promise.all(writes); + } +} + +async function seedLeave() { + for (const t of leaveTypes) { + await col("leaveTypes").doc(t.id).set({ companyId: CID, ...t, active: true, updatedAt: now }); + } + for (const e of employees) { + for (const t of leaveTypes) { + await col("leaveBalances").doc(`${e.id}_${t.id}_${YEAR}`).set({ + employeeId: e.id, + leaveTypeId: t.id, + periodYear: YEAR, + entitledDays: t.id === "lt_annual" ? 20 : 10, + accruedDays: 0, + usedDays: 2, + carriedOverDays: 0, + pendingDays: 0, + updatedAt: now, + }); + } + } + + // Pending requests routed to the admin so they show in the approvals queue. + const pending = [ + { id: "lr_1", emp: "emp_ahmad", name: "احمد کریمی", type: "lt_annual", start: isoDaysAgo(-3), end: isoDaysAgo(-5), days: 3, reason: "سفر خانوادگی به هرات" }, + { id: "lr_2", emp: "emp_fatima", name: "فاطمه احمدی", type: "lt_sick", start: isoDaysAgo(-1), end: isoDaysAgo(-1), days: 1, reason: "مریضی و مراجعه به داکتر" }, + { id: "lr_3", emp: "emp_omar", name: "عمر صدیقی", type: "lt_annual", start: isoDaysAgo(-7), end: isoDaysAgo(-9), days: 3, reason: "امور شخصی" }, + ]; + for (const r of pending) { + await col("leaveRequests").doc(r.id).set({ + companyId: CID, + employeeId: r.emp, + employeeName: r.name, + leaveTypeId: r.type, + startDate: r.start, + endDate: r.end, + startHalfDay: false, + endHalfDay: false, + days: r.days, + reason: r.reason, + status: "PENDING", + currentApproverId: "emp_admin", + decidedAt: null, + decidedBy: null, + decisionNote: null, + createdAt: now, + updatedAt: now, + }); + } + + // Pending attendance-correction requests routed to the admin. + const regs = [ + { id: "reg_1", emp: "emp_ahmad", name: "احمد کریمی", day: isoDaysAgo(3), inH: 8, outH: 16, reason: "فراموش کردم خروج بزنم" }, + { id: "reg_2", emp: "emp_yusuf", name: "یوسف حبیبی", day: isoDaysAgo(2), inH: 8, outH: 15, reason: "سیستم حاضری خراب بود" }, + ]; + for (const r of regs) { + await col("regularizations").doc(r.id).set({ + companyId: CID, + employeeId: r.emp, + employeeName: r.name, + date: r.day, + requestedInAt: at(r.day, r.inH, 0), + requestedOutAt: at(r.day, r.outH, 0), + reason: r.reason, + status: "PENDING", + currentApproverId: "emp_admin", + decidedAt: null, + decidedBy: null, + decisionNote: null, + createdAt: now, + updatedAt: now, + }); + } +} + +async function seedPayroll() { + // Per-employee basic salary. + for (const [empId, basic] of Object.entries(employeeSalaries)) { + await col("employeeSalaries").doc(empId).set({ + employeeId: empId, + structureId: null, + basicAmount: basic, + currency: "AFN", + effectiveFrom: "2024-03-21", + revisionReason: "Initial", + updatedAt: now, + }); + } + + // The finished payroll run the demo advertises is produced by the real + // engine, not written by hand here. + // + // It used to be hand-written, and the two drifted: the seeded payslips knew + // nothing about per-employee allowances, and pressing "Run payroll" in the + // demo replaced them with different numbers. Calling computePayrollRun means + // what a visitor first sees is exactly what the product produces — including + // the ledger accrual, which this function posts itself, so the hand-written + // journal entry is gone with it. + const { computePayrollRun } = requirePayroll(); + const sh = gregToShamsi(new Date()); + await computePayrollRun(CID, sh.year, sh.month, "emp_admin", "AFN"); +} + +/** + * The compiled payroll service. + * + * seed.js is plain CommonJS and the service is TypeScript, so this reaches for + * the build output. Failing here with an explanation beats a bare MODULE_NOT_FOUND + * from inside a nightly reset. + */ +function requirePayroll() { + try { + return require("./lib/services/payroll"); + } catch (err) { + // Only a resolution failure means "not built". Anything else — a throw from + // inside the module itself — must reach the log with its own stack rather + // than wearing a misleading explanation. + if (err.code === "MODULE_NOT_FOUND" && /services[\\/]payroll/.test(err.message)) { + throw new Error( + "Cannot load lib/services/payroll — build the functions first:\n" + + " npm --prefix backend/functions run build", + ); + } + throw err; + } +} + +async function seedExtras() { + for (const a of employeeComponents) { + await col("employeeComponents") + .doc(`${a.employeeId}__${a.componentId}`) + .set({ companyId: CID, ...a, updatedAt: now }); + } + + for (const c of salaryComponents) { + await col("salaryComponents").doc(c.id).set({ companyId: CID, ...c, updatedAt: now }); + } + for (const a of announcements) { + await col("announcements").doc(a.id).set({ + companyId: CID, + title: a.title, + body: a.body, + priority: a.priority, + publishedAt: now, + expiresAt: null, + createdByName: a.createdByName, + updatedAt: now, + }); + } + // Holiday calendar with the Afghan weekend note + a public holiday example. + await col("holidayCalendars").doc("hc_2026").set({ + companyId: CID, + name: "تقویم رخصتی ۱۴۰۵", + year: YEAR, + branchIds: ["br_main"], + weekendDays: ["FRIDAY"], + updatedAt: now, + }); +} + +/** + * Work assignment: two projects, two crews, and a week of scheduled work. + * + * Dated relative to today rather than fixed, so the demo answers "what am I on + * today" with something on it every day it is opened — including the Android + * app's dashboard, which is the whole point of the feature. + * + * Spans are deliberately mixed: single days, a job running through today, and + * work dated ahead, so the "next working day" card is never empty either. + */ +const projects = [ + { + id: "prj_darulaman", + name: "برج دارالامان", + code: "DRL", + description: "اعمار بلاک B — ۸ منزل، قرارداد شاروالی کابل", + status: "ACTIVE", + }, + { + id: "prj_school", + name: "بازسازی مکتب نمبر ۴", + code: "SCH-4", + description: "ترمیم صنف‌ها و سیستم برق", + status: "ACTIVE", + }, +]; + +const workTeams = [ + { + id: "tm_concrete", + name: "تیم کانکریت", + leadId: "emp_yusuf", + memberIds: ["emp_ahmad", "emp_omar", "emp_yusuf"], + }, + { + id: "tm_electric", + name: "تیم برق", + leadId: "emp_fatima", + memberIds: ["emp_fatima", "emp_maryam"], + }, +]; + +const NAMES = Object.fromEntries( + employees.map((e) => [e.id, `${e.firstName} ${e.lastName}`]), +); + +/** startOffset/endOffset are days from today; 0 = today, -1 = tomorrow. */ +const workTasks = [ + { + id: "tk_slab", project: "prj_darulaman", team: "tm_concrete", + title: "قالب‌بندی و ریختن کانکریت منزل سوم", + detail: "قبل از ریختن، آرماتوربندی توسط انجنیر ساحه کنترول شود.", + // Two days on purpose: the pour, then the curing check. It also means the + // Android demo login has something on its "next working day" card. + location: "بلاک B — منزل سوم", start: 0, end: -1, status: "IN_PROGRESS", priority: "HIGH", + }, + { + id: "tk_rebar", project: "prj_darulaman", assignees: ["emp_omar"], + title: "آرماتوربندی منزل چهارم", + detail: null, location: "بلاک B — منزل چهارم", start: 1, end: -2, + status: "IN_PROGRESS", priority: "NORMAL", + }, + { + id: "tk_wiring", project: "prj_school", team: "tm_electric", + title: "کشیدن وایرینگ صنف‌های ۱ تا ۴", + detail: "کیبل ۲.۵ ملی‌متر از گدام گرفته شود.", + location: "منزل اول", start: 0, end: 0, status: "PLANNED", priority: "NORMAL", + }, + { + id: "tk_panel", project: "prj_school", assignees: ["emp_fatima"], + title: "نصب پنل برق مرکزی", + detail: null, location: "دهلیز مرکزی", start: -1, end: -1, + status: "PLANNED", priority: "HIGH", + }, + { + id: "tk_survey", project: "prj_darulaman", assignees: ["emp_maryam"], + title: "سروی و نشانی ستون‌های منزل پنجم", + detail: null, location: "بلاک B", start: -1, end: -1, + status: "PLANNED", priority: "NORMAL", + }, + { + id: "tk_cleanup", project: "prj_darulaman", team: "tm_concrete", + title: "پاک‌کاری ساحه و جمع‌آوری قالب‌ها", + detail: null, location: "بلاک B", start: -3, end: -3, + status: "PLANNED", priority: "LOW", + }, +]; + +async function seedWork() { + for (const p of projects) { + await col("projects").doc(p.id).set({ + companyId: CID, + name: p.name, + code: p.code, + description: p.description, + branchId: "br_main", + managerId: "emp_admin", + status: p.status, + startDate: null, + endDate: null, + createdBy: "emp_admin", + createdAt: now, + updatedAt: now, + }); + } + + for (const t of workTeams) { + await col("projectTeams").doc(t.id).set({ + companyId: CID, + name: t.name, + projectId: null, + leadId: t.leadId, + memberIds: [...t.memberIds].sort(), + active: true, + createdBy: "emp_admin", + createdAt: now, + updatedAt: now, + }); + } + + const teamsById = Object.fromEntries(workTeams.map((t) => [t.id, t])); + for (const t of workTasks) { + const team = t.team ? teamsById[t.team] : null; + // Same rule as services/work.ts: a task is assigned to people, and a team + // is expanded to its members when it is written. + const assigneeIds = [ + ...new Set([...(team ? team.memberIds : []), ...(t.assignees ?? [])]), + ].sort(); + const project = projects.find((p) => p.id === t.project); + + await col("tasks").doc(t.id).set({ + companyId: CID, + projectId: project.id, + projectName: project.name, + title: t.title, + detail: t.detail, + location: t.location, + startDate: isoDaysAgo(t.start), + endDate: isoDaysAgo(t.end), + status: t.status, + priority: t.priority, + teamId: team ? team.id : null, + teamName: team ? team.name : null, + assigneeIds, + assigneeNames: assigneeIds.map((id) => NAMES[id] ?? id), + statusNote: null, + completedAt: null, + createdBy: "emp_admin", + createdAt: now, + updatedAt: now, + }); + } +} + +async function seedFinance() { + // Chart of accounts (mirrors DEFAULT_ACCOUNTS in services/accounting.ts). + const accounts = [ + ["1000", "Cash", "ASSET"], ["1010", "Bank", "ASSET"], ["1200", "Accounts Receivable", "ASSET"], + ["2000", "Accounts Payable", "LIABILITY"], ["2100", "Salaries Payable", "LIABILITY"], ["2200", "Taxes Payable", "LIABILITY"], + // Credited by the payroll engine; without them the run would create the + // codes itself and this list would stop mirroring the product's chart. + ["2300", "Employee Withholdings", "LIABILITY"], ["2400", "Employer Contributions Payable", "LIABILITY"], + ["3000", "Owner's Equity", "EQUITY"], + ["4000", "Service Revenue", "INCOME"], ["4100", "Other Income", "INCOME"], + ["5000", "Salaries & Wages", "EXPENSE"], ["5100", "Rent", "EXPENSE"], ["5200", "Utilities", "EXPENSE"], + ["5300", "Office Supplies", "EXPENSE"], ["5400", "Travel & Transport", "EXPENSE"], ["5900", "Other Expenses", "EXPENSE"], + ]; + for (const [code, name, type] of accounts) { + await col("accounts").doc(code).set({ code, name, type, active: true, createdAt: now }); + } + + // 3 months of revenue + rent for a populated income-vs-expense trend. + const monthIso = (m) => { const d = new Date(); d.setUTCMonth(d.getUTCMonth() - m, 15); return d.toISOString().slice(0, 10); }; + for (const [m, amount] of [[0, 320000], [1, 280000], [2, 350000]]) { + const date = monthIso(m); + await col("journalEntries").doc(`je_rev_${m}`).set({ + date, memo: "Project invoice", reference: null, source: "MANUAL", + lines: [ + { accountCode: "1010", accountName: "Bank", debit: amount, credit: 0 }, + { accountCode: "4000", accountName: "Service Revenue", debit: 0, credit: amount }, + ], + totalDebit: amount, createdBy: "emp_finance", createdAt: now, + }); + await col("journalEntries").doc(`je_rent_${m}`).set({ + date, memo: "Monthly office rent", reference: null, source: "MANUAL", + lines: [ + { accountCode: "5100", accountName: "Rent", debit: 40000, credit: 0 }, + { accountCode: "1010", accountName: "Bank", debit: 0, credit: 40000 }, + ], + totalDebit: 40000, createdBy: "emp_finance", createdAt: now, + }); + } + + // Expenses across the lifecycle, with matching ledger postings. + const expenses = [ + { id: "exp_1", category: "utilities", vendor: "برشنا شرکت", description: "قبض برق حمل", amount: 8500, status: "PAID", accountCode: "5200" }, + { id: "exp_2", category: "supplies", vendor: "قرطاسیه نور", description: "لوازم دفتری", amount: 4200, status: "APPROVED", accountCode: "5300" }, + { id: "exp_3", category: "travel", vendor: "ترانسپورت کابل", description: "سفر پروژه هرات", amount: 12000, status: "DRAFT", accountCode: "5400" }, + { id: "exp_4", category: "services", vendor: "خدمات انترنت", description: "انترنت ماهانه", amount: 6000, status: "DRAFT", accountCode: "5900" }, + ]; + for (const e of expenses) { + await col("expenses").doc(e.id).set({ + companyId: CID, category: e.category, vendor: e.vendor, description: e.description, + amount: e.amount, currency: "AFN", date: TODAY, status: e.status, accountCode: e.accountCode, + createdBy: "emp_finance", + createdAt: now, + decidedBy: e.status === "DRAFT" ? null : "emp_finance", + decidedAt: e.status === "DRAFT" ? null : now, + }); + if (e.status === "APPROVED" || e.status === "PAID") { + await col("journalEntries").doc(`je_exp_${e.id}`).set({ + date: TODAY, memo: `Expense: ${e.vendor}`, reference: e.id, source: "EXPENSE", + lines: [ + { accountCode: e.accountCode, accountName: e.category, debit: e.amount, credit: 0 }, + { accountCode: "2000", accountName: "Accounts Payable", debit: 0, credit: e.amount }, + ], + totalDebit: e.amount, createdBy: "emp_finance", createdAt: now, + }); + } + if (e.status === "PAID") { + await col("journalEntries").doc(`je_exppay_${e.id}`).set({ + date: TODAY, memo: `Payment: ${e.vendor}`, reference: e.id, source: "EXPENSE", + lines: [ + { accountCode: "2000", accountName: "Accounts Payable", debit: e.amount, credit: 0 }, + { accountCode: "1010", accountName: "Bank", debit: 0, credit: e.amount }, + ], + totalDebit: e.amount, createdBy: "emp_finance", createdAt: now, + }); + } + } +} + +async function main() { + // Report where the writes are actually going, not which flag was passed — + // when the reset scheduler imports this there is no --target, and saying + // "emulators" while writing to a real project would mislead anyone reading + // the logs afterwards. + const usingEmulators = Boolean(process.env.FIRESTORE_EMULATOR_HOST); + console.log( + usingEmulators + ? `Seeding demo tenant into emulators (project=${PROJECT_ID})…` + : `Seeding demo tenant into the REAL project ${PROJECT_ID}…`, + ); + await seedOrg(); + await seedAuth(); + await seedAttendance(); + await seedLeave(); + await seedExtras(); + await seedWork(); + // Finance before payroll: the run posts its accrual to the chart of accounts, + // so the chart has to exist first. computePayrollRun would create the codes it + // needs on its own, but then seedFinance would write over them afterwards. + await seedFinance(); + await seedPayroll(); + console.log(`\n✅ Done. Sample logins (password: ${PASSWORD}):`); + console.log(" admin@worktrack.af — COMPANY_ADMIN (web portal)"); + console.log(" hr@worktrack.af — HR_ADMIN"); + console.log(" finance@worktrack.af — FINANCE_ADMIN (finance & accounting)"); + console.log(" ahmad@worktrack.af — EMPLOYEE (Android app)"); +} + +module.exports = { seedDemoTenant: main, CID, PASSWORD, DEMO_UIDS: authUsers.map((u) => u.uid) }; + +if (IS_CLI) { + main() + .then(() => process.exit(0)) + .catch((err) => { + console.error("Seed failed:", err); + process.exit(1); + }); +} diff --git a/backend/functions/src/app.ts b/backend/functions/src/app.ts new file mode 100644 index 0000000..68b9a0f --- /dev/null +++ b/backend/functions/src/app.ts @@ -0,0 +1,102 @@ +import cors from "cors"; +import express from "express"; +import { isOriginAllowed } from "./lib/cors"; +import { errorHandler } from "./lib/errors"; +import { requireAuth } from "./middleware/auth"; +import { enforceDeviceLicense } from "./middleware/deviceGuard"; +import { vendorRouter } from "./routes/vendor"; +import { supportRouter } from "./routes/support"; +import { meRouter } from "./routes/me"; +import { attendanceRouter } from "./routes/attendance"; +import { leaveRouter } from "./routes/leave"; +import { payslipsRouter } from "./routes/payslips"; +import { announcementsRouter } from "./routes/announcements"; +import { employeesRouter } from "./routes/employees"; +import { analyticsRouter } from "./routes/analytics"; +import { advancesRouter } from "./routes/advances"; +import { documentsRouter } from "./routes/documents"; +import { notificationsRouter } from "./routes/notifications"; +import { pieceWorkRouter } from "./routes/pieceWork"; +import { payrollRouter } from "./routes/payroll"; +import { financeRouter } from "./routes/finance"; +import { devicesRouter } from "./routes/devices"; +import { calendarRouter } from "./routes/calendar"; +import { companyRouter } from "./routes/company"; +import { publicRouter } from "./routes/public"; +import { settingsRouter } from "./routes/settings"; +import { shiftsRouter } from "./routes/shifts"; +import { workRouter } from "./routes/work"; +import { kioskRouter } from "./routes/kiosk"; +import { syncRouter } from "./routes/sync"; + +/** + * WorkTrack REST API v1. Middleware chain per request: + * cors -> json -> requireAuth (verify token + tenant claims) -> route + * (route-level RBAC) -> handler -> problem+json error handler. + */ +export function createApp(): express.Express { + const app = express(); + app.disable("x-powered-by"); + app.use( + cors({ + // Refusing an origin means omitting the CORS headers rather than failing + // the request: the browser blocks the response, and a non-browser caller + // (the Android app) is unaffected. Passing an Error here would turn every + // unknown origin into a 500 instead. + origin: (origin, callback) => callback(null, isOriginAllowed(origin)), + maxAge: 3600, + }), + ); + app.use(express.json({ limit: "1mb" })); + + // Unauthenticated liveness probe for uptime monitoring. + app.get("/v1/health", (_req, res) => { + res.json({ data: { status: "ok" } }); + }); + + // Public, unauthenticated routes (company self-signup) — mounted BEFORE the + // auth middleware so a new company can be created without a token. + app.use("/v1/public", publicRouter); + + // The vendor console. Mounted OUTSIDE the tenant router on purpose: requireAuth + // demands cid/eid, which a vendor account does not have, and these routes take + // the company id from the URL rather than the token. requireVendor is what + // makes that safe — see middleware/vendor.ts. + app.use("/v1/vendor", vendorRouter); + + const v1 = express.Router(); + v1.use(requireAuth); + // Mounted before the device guard: a phone cannot claim its licence seat if + // holding a seat is the precondition for being allowed to ask. + v1.use("/devices", devicesRouter); + // Also before the device guard, and for the same reason: the customer most + // likely to need support is the one the licence has just locked out, and a + // support channel they cannot reach when the product refuses them is not a + // support channel. + v1.use("/support", supportRouter); + v1.use(enforceDeviceLicense); + v1.use("/me", meRouter); + v1.use("/employees", employeesRouter); + v1.use("/attendance", attendanceRouter); + v1.use("/leave", leaveRouter); + v1.use("/payslips", payslipsRouter); + v1.use("/payroll", payrollRouter); + v1.use("/advances", advancesRouter); + v1.use("/piece-work", pieceWorkRouter); + v1.use("/notifications", notificationsRouter); + v1.use("/documents", documentsRouter); + v1.use("/finance", financeRouter); + v1.use("/announcements", announcementsRouter); + v1.use("/analytics", analyticsRouter); + v1.use("/shifts", shiftsRouter); + v1.use("/work", workRouter); + v1.use("/calendar", calendarRouter); + v1.use("/company", companyRouter); + v1.use("/settings", settingsRouter); + v1.use("/kiosk", kioskRouter); + v1.use("/sync", syncRouter); + app.use("/v1", v1); + + app.use(errorHandler); + return app; +} diff --git a/backend/functions/src/config.ts b/backend/functions/src/config.ts new file mode 100644 index 0000000..807e23a --- /dev/null +++ b/backend/functions/src/config.ts @@ -0,0 +1,8 @@ +import { defineSecret } from "firebase-functions/params"; + +/** + * HMAC secret for kiosk TOTP QR tokens. Managed via Secret Manager: + * firebase functions:secrets:set KIOSK_HMAC_SECRET + * For the emulator, place a value in functions/.secret.local. + */ +export const kioskSecret = defineSecret("KIOSK_HMAC_SECRET"); diff --git a/backend/functions/src/index.ts b/backend/functions/src/index.ts new file mode 100644 index 0000000..1a518f5 --- /dev/null +++ b/backend/functions/src/index.ts @@ -0,0 +1,141 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { onSchedule } from "firebase-functions/v2/scheduler"; +import { companiesDueForPurge, purgeCompany } from "./services/companyDeletion"; +import { createApp } from "./app"; +import { kioskSecret } from "./config"; +import { runAttendanceAudit } from "./services/integrity"; +import { runDocumentWatch } from "./services/documentWatch"; +import { resetDemoTenant, DemoResetRefused } from "./services/demo-reset"; + +// Deploy marker: v1.1 (finance + face recognition endpoints). + +/** + * The WorkTrack REST API v1, served as a single HTTPS function behind + * `https://api.worktrack.app` (Hosting rewrite or Cloud Load Balancer). + * Scaling, TLS, and DDoS absorption are delegated to Google Front End. + */ +export const api = onRequest( + { + region: "us-central1", + secrets: [kioskSecret], + minInstances: 0, + maxInstances: 100, + concurrency: 80, + memory: "512MiB", + timeoutSeconds: 60, + }, + createApp(), +); + +/** + * Nightly check that attendance which was recorded actually reached the board. + * + * Runs after the Kabul day has closed, covering yesterday and today. Findings + * are logged under ATTENDANCE_INTEGRITY and written to `integrityReports`, so + * a repeat of the silent projection failure surfaces within a day instead of + * whenever somebody happens to notice their staff marked absent. + */ +/** + * Nightly check of the employee document register. + * + * A register nobody opens is not a control. This is what makes it one: whoever + * can act on it is told, unasked, that a work permit runs out in three weeks — + * rather than finding out four months after a contract lapsed, which means + * somebody has been working without one. + */ +export const documentExpiryWatch = onSchedule( + { + region: "us-central1", + schedule: "every day 03:00", + timeZone: "Asia/Kabul", + memory: "256MiB", + timeoutSeconds: 300, + }, + async () => { + const result = await runDocumentWatch(); + console.info("DOCUMENT_WATCH", JSON.stringify(result)); + }, +); + +export const attendanceIntegrityAudit = onSchedule( + { + region: "us-central1", + schedule: "every day 02:00", + timeZone: "Asia/Kabul", + memory: "256MiB", + timeoutSeconds: 300, + }, + async () => { + await runAttendanceAudit(); + }, +); + +/** + * Nightly reset of the public demo tenant, so every visitor arrives to the same + * clean company rather than to whatever the previous one typed in. + * + * Deployed to every project but harmless outside the demo: resetDemoTenant + * refuses to touch a project that is not the demo one, and a refusal is logged + * and swallowed rather than retried, so this cannot become a nightly alarm on + * production. + */ +export const demoTenantReset = onSchedule( + { + region: "us-central1", + schedule: "every day 03:30", + timeZone: "Asia/Kabul", + memory: "512MiB", + timeoutSeconds: 540, + }, + async () => { + try { + const outcome = await resetDemoTenant(); + console.log("DEMO_RESET", JSON.stringify(outcome)); + } catch (err) { + if (err instanceof DemoResetRefused) { + console.log("DEMO_RESET_SKIPPED", err.message); + return; + } + throw err; + } + }, +); + +/** + * Purges the company accounts whose grace period has elapsed. + * + * The destructive half of account closure. Every safeguard lives in + * purgeCompany, which re-reads the request and refuses anything that is not an + * explicit, matured, scheduled deletion — so this job cannot widen its own + * blast radius, and a bug here deletes nothing. + * + * One company failing does not stop the rest: the failure is logged and the + * loop continues, because a tenant stuck mid-purge is worse than a slow one. + */ +export const companyDeletionPurge = onSchedule( + { + region: "us-central1", + schedule: "every day 04:00", + timeZone: "Asia/Kabul", + memory: "512MiB", + timeoutSeconds: 540, + }, + async () => { + const today = new Date().toISOString().slice(0, 10); + const due = await companiesDueForPurge(today); + if (due.length === 0) return; + + console.warn("COMPANY_PURGE_START", JSON.stringify({ count: due.length, today })); + for (const cid of due) { + try { + const result = await purgeCompany(cid, today); + console.warn("COMPANY_PURGE_OK", JSON.stringify(result)); + } catch (err) { + console.error( + "COMPANY_PURGE_FAILED", + JSON.stringify({ companyId: cid, error: (err as Error).message }), + ); + } + } + }, +); diff --git a/backend/functions/src/lib/cors.test.ts b/backend/functions/src/lib/cors.test.ts new file mode 100644 index 0000000..6b9a6b0 --- /dev/null +++ b/backend/functions/src/lib/cors.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { allowedOrigins, isOriginAllowed } from "./cors"; + +/** + * The API ran with `cors({ origin: true })`, which reflects whatever Origin the + * caller sends. Any page on any domain could therefore call the API from a + * signed-in manager's browser and read the response. + */ + +const PROD: NodeJS.ProcessEnv = {}; +const EMULATOR: NodeJS.ProcessEnv = { FUNCTIONS_EMULATOR: "true" }; + +describe("CORS origin policy", () => { + it("allows the portal's own Hosting origins", () => { + expect(isOriginAllowed("https://worktrack-prod.web.app", PROD)).toBe(true); + expect(isOriginAllowed("https://worktrack-prod.firebaseapp.com", PROD)).toBe(true); + }); + + it("refuses an origin that is not on the list", () => { + expect(isOriginAllowed("https://evil.example", PROD)).toBe(false); + }); + + it("refuses a look-alike of an allowed origin", () => { + expect(isOriginAllowed("https://worktrack-prod.web.app.evil.example", PROD)).toBe(false); + expect(isOriginAllowed("http://worktrack-prod.web.app", PROD)).toBe(false); // not https + }); + + it("allows a request with no Origin header", () => { + // The Android app uses OkHttp, which sends no Origin. CORS is a browser + // mechanism; these callers still have to pass Firebase Auth. + expect(isOriginAllowed(undefined, PROD)).toBe(true); + }); + + it("does not allow the dev server in production", () => { + expect(isOriginAllowed("http://localhost:5173", PROD)).toBe(false); + }); + + it("allows the dev server only when running against the emulator", () => { + expect(isOriginAllowed("http://localhost:5173", EMULATOR)).toBe(true); + expect(isOriginAllowed("http://127.0.0.1:5173", EMULATOR)).toBe(true); + }); + + it("takes extra origins from CORS_ORIGINS so a custom domain needs no code change", () => { + const env = { CORS_ORIGINS: "https://worktrack.af, https://app.worktrack.af" }; + expect(isOriginAllowed("https://worktrack.af", env)).toBe(true); + expect(isOriginAllowed("https://app.worktrack.af", env)).toBe(true); + expect(isOriginAllowed("https://other.af", env)).toBe(false); + }); + + it("ignores blank entries in CORS_ORIGINS", () => { + // A trailing comma must not turn into an empty allowed origin. + expect(allowedOrigins({ CORS_ORIGINS: "https://worktrack.af,," })).not.toContain(""); + }); +}); diff --git a/backend/functions/src/lib/cors.ts b/backend/functions/src/lib/cors.ts new file mode 100644 index 0000000..02d1b96 --- /dev/null +++ b/backend/functions/src/lib/cors.ts @@ -0,0 +1,50 @@ +/** + * Which browser origins may call the API. + * + * In production almost nothing needs this. Firebase Hosting serves the manager + * portal and rewrites /v1/** to this function, so the portal's calls are + * same-origin; and the Android app is not a browser, so CORS never applies to + * it. The allow-list therefore covers the exceptions: a portal loaded from a + * Hosting domain that calls an absolute API URL, a custom domain, and the Vite + * dev server. + * + * `origin: true` reflected whatever Origin the caller sent, which let a page on + * any domain read authenticated responses from a signed-in manager's browser. + */ + +/** Firebase Hosting serves the portal on both of these by default. */ +const HOSTING_ORIGINS = [ + "https://worktrack-prod.web.app", + "https://worktrack-prod.firebaseapp.com", +]; + +/** Vite dev server — allowed only when running against the local emulator. */ +const DEV_ORIGINS = ["http://localhost:5173", "http://127.0.0.1:5173"]; + +/** + * Additional origins come from CORS_ORIGINS (comma-separated), so connecting a + * custom domain in Hosting is a config change rather than a code change. Set it + * in backend/functions/.env, which the Functions runtime loads on deploy: + * CORS_ORIGINS=https://worktrack.af,https://app.worktrack.af + */ +export function allowedOrigins(env: NodeJS.ProcessEnv = process.env): string[] { + const extra = (env.CORS_ORIGINS ?? "") + .split(",") + .map((o) => o.trim()) + .filter(Boolean); + // FUNCTIONS_EMULATOR is set by the Firebase emulator and never in production. + const dev = env.FUNCTIONS_EMULATOR === "true" ? DEV_ORIGINS : []; + return [...HOSTING_ORIGINS, ...extra, ...dev]; +} + +export function isOriginAllowed( + origin: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): boolean { + // No Origin header means the caller is not a browser: the Android app (OkHttp + // sends none), curl, or a server-to-server call. CORS is a browser mechanism, + // so refusing these would break the mobile app without protecting anything — + // the request still has to pass Firebase Auth and RBAC either way. + if (!origin) return true; + return allowedOrigins(env).includes(origin); +} diff --git a/backend/functions/src/lib/errors.ts b/backend/functions/src/lib/errors.ts new file mode 100644 index 0000000..df8bc9d --- /dev/null +++ b/backend/functions/src/lib/errors.ts @@ -0,0 +1,108 @@ +import type { NextFunction, Request, Response } from "express"; + +/** Canonical machine-readable error codes (mirrored by the Android client). */ +export const ErrorCodes = { + UNAUTHENTICATED: "UNAUTHENTICATED", + PERMISSION_DENIED: "PERMISSION_DENIED", + TENANT_MISMATCH: "TENANT_MISMATCH", + NOT_FOUND: "NOT_FOUND", + VALIDATION_FAILED: "VALIDATION_FAILED", + IDEMPOTENCY_REPLAY: "IDEMPOTENCY_REPLAY", + GEOFENCE_VIOLATION: "GEOFENCE_VIOLATION", + KIOSK_TOKEN_INVALID: "KIOSK_TOKEN_INVALID", + FACE_ALREADY_ENROLLED: "FACE_ALREADY_ENROLLED", + INSUFFICIENT_LEAVE_BALANCE: "INSUFFICIENT_LEAVE_BALANCE", + INVALID_STATE: "INVALID_STATE", + UNSUPPORTED_RESOURCE: "UNSUPPORTED_RESOURCE", + CONFLICT: "CONFLICT", + RATE_LIMITED: "RATE_LIMITED", + EMAIL_NOT_VERIFIED: "EMAIL_NOT_VERIFIED", + LICENSE_INACTIVE: "LICENSE_INACTIVE", + LICENSE_LIMIT_REACHED: "LICENSE_LIMIT_REACHED", + DEVICE_REVOKED: "DEVICE_REVOKED", + DEVICE_NOT_ACTIVATED: "DEVICE_NOT_ACTIVATED", + INTERNAL: "INTERNAL", +} as const; + +export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; + +/** Application error carrying an HTTP status and a stable code. */ +export class ApiError extends Error { + constructor( + readonly status: number, + readonly code: ErrorCode, + readonly detail: string, + readonly fieldErrors: Record = {}, + /** Seconds until the caller may retry; emitted as the Retry-After header. */ + readonly retryAfterSeconds?: number, + ) { + super(detail); + } + + static unauthenticated(detail = "Missing or invalid credentials"): ApiError { + return new ApiError(401, ErrorCodes.UNAUTHENTICATED, detail); + } + + static permissionDenied(detail = "Not allowed"): ApiError { + return new ApiError(403, ErrorCodes.PERMISSION_DENIED, detail); + } + + static notFound(detail = "Resource not found"): ApiError { + return new ApiError(404, ErrorCodes.NOT_FOUND, detail); + } + + static validation(detail: string, fieldErrors: Record = {}): ApiError { + return new ApiError(422, ErrorCodes.VALIDATION_FAILED, detail, fieldErrors); + } + + static business(code: ErrorCode, detail: string): ApiError { + return new ApiError(422, code, detail); + } + + static rateLimited(detail: string, retryAfterSeconds: number): ApiError { + return new ApiError(429, ErrorCodes.RATE_LIMITED, detail, {}, retryAfterSeconds); + } +} + +/** RFC 7807 problem+json responder. */ +export function sendProblem(res: Response, error: ApiError): void { + // Tells a well-behaved client how long to wait instead of retrying at once. + if (error.retryAfterSeconds !== undefined) { + res.set("Retry-After", String(error.retryAfterSeconds)); + } + res + .status(error.status) + .type("application/problem+json") + .json({ + type: `https://api.worktrack.app/errors/${error.code}`, + title: error.code, + status: error.status, + code: error.code, + detail: error.detail, + ...(Object.keys(error.fieldErrors).length > 0 ? { fieldErrors: error.fieldErrors } : {}), + }); +} + +/** Terminal express error handler: everything unexpected becomes a 500 problem. */ +export function errorHandler( + err: unknown, + _req: Request, + res: Response, + _next: NextFunction, +): void { + if (err instanceof ApiError) { + sendProblem(res, err); + return; + } + console.error("Unhandled API error", err); + sendProblem(res, new ApiError(500, ErrorCodes.INTERNAL, "Internal server error")); +} + +/** Wraps async handlers so rejections reach the error handler. */ +export function asyncHandler( + fn: (req: Request, res: Response, next: NextFunction) => Promise, +): (req: Request, res: Response, next: NextFunction) => void { + return (req, res, next) => { + fn(req, res, next).catch(next); + }; +} diff --git a/backend/functions/src/lib/face-math.test.ts b/backend/functions/src/lib/face-math.test.ts new file mode 100644 index 0000000..95e55cd --- /dev/null +++ b/backend/functions/src/lib/face-math.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { + cosineSimilarity, + compareEmbeddings, + embeddingSchema, + FACE_MATCH_THRESHOLD, +} from "./face-math"; + +// A deterministic pseudo-embedding generator so tests don't depend on a model. +function vec(seed: number, dim = 128): number[] { + const out: number[] = []; + let x = seed; + for (let i = 0; i < dim; i++) { + x = (x * 1103515245 + 12345) & 0x7fffffff; + out.push((x / 0x7fffffff) * 2 - 1); + } + return out; +} + +/** Nudge a vector slightly so it stays highly similar (same "person"). */ +function jitter(v: number[], amount: number): number[] { + return v.map((n, i) => n + (i % 2 === 0 ? amount : -amount) * 0.5); +} + +describe("cosineSimilarity", () => { + it("is 1 for identical vectors", () => { + const v = vec(1); + expect(cosineSimilarity(v, v)).toBeCloseTo(1, 6); + }); + + it("is -1 for exactly opposite vectors", () => { + const v = vec(2); + expect(cosineSimilarity(v, v.map((n) => -n))).toBeCloseTo(-1, 6); + }); + + it("is ~0 for orthogonal vectors", () => { + expect(cosineSimilarity([1, 0, 0, 0], [0, 1, 0, 0])).toBeCloseTo(0, 6); + }); + + it("returns -1 for mismatched lengths", () => { + expect(cosineSimilarity([1, 2, 3], [1, 2])).toBe(-1); + }); + + it("returns -1 for a zero vector (no direction)", () => { + expect(cosineSimilarity([0, 0, 0], [1, 2, 3])).toBe(-1); + }); +}); + +describe("compareEmbeddings", () => { + it("matches the same face (identical embedding)", () => { + const v = vec(7); + const r = compareEmbeddings(v, v); + expect(r.match).toBe(true); + expect(r.similarity).toBe(1); + expect(r.threshold).toBe(FACE_MATCH_THRESHOLD); + }); + + it("matches a lightly jittered capture of the same face", () => { + const enrolled = vec(9); + const r = compareEmbeddings(enrolled, jitter(enrolled, 0.02)); + expect(r.match).toBe(true); + expect(r.similarity).toBeGreaterThanOrEqual(FACE_MATCH_THRESHOLD); + }); + + it("rejects a different person", () => { + const r = compareEmbeddings(vec(11), vec(999)); + expect(r.match).toBe(false); + expect(r.similarity).toBeLessThan(FACE_MATCH_THRESHOLD); + }); + + it("rounds similarity to 3 decimals", () => { + const r = compareEmbeddings(vec(3), vec(3)); + expect(Number.isInteger(r.similarity * 1000)).toBe(true); + }); +}); + +describe("embeddingSchema", () => { + it("accepts a 128-d numeric embedding", () => { + expect(embeddingSchema.safeParse({ embedding: vec(1, 128) }).success).toBe(true); + }); + + it("rejects an embedding that is too short", () => { + expect(embeddingSchema.safeParse({ embedding: [1, 2, 3] }).success).toBe(false); + }); + + it("rejects non-finite values", () => { + const bad = vec(1, 128); + bad[0] = Number.POSITIVE_INFINITY; + expect(embeddingSchema.safeParse({ embedding: bad }).success).toBe(false); + }); + + it("rejects a non-array embedding", () => { + expect(embeddingSchema.safeParse({ embedding: "nope" }).success).toBe(false); + }); +}); diff --git a/backend/functions/src/lib/face-math.ts b/backend/functions/src/lib/face-math.ts new file mode 100644 index 0000000..ed3c545 --- /dev/null +++ b/backend/functions/src/lib/face-math.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +/** + * Pure face-embedding math — no Firebase imports, so it is unit-testable in + * isolation. Embeddings are the on-device face vectors (MobileFaceNet=192, + * FaceNet=128); identity matching is cosine similarity. + */ + +/** + * Cosine-similarity threshold above which two embeddings are treated as the + * same person. Tune against real enrollment captures before a wide rollout. + */ +export const FACE_MATCH_THRESHOLD = 0.6; + +// Accepted embedding sizes, kept flexible so the model can change without a +// schema migration. +export const MIN_EMBEDDING_DIM = 64; +export const MAX_EMBEDDING_DIM = 1024; + +/** Request body carrying an on-device face embedding (enroll & verify). */ +export const embeddingSchema = z.object({ + embedding: z.array(z.number().finite()).min(MIN_EMBEDDING_DIM).max(MAX_EMBEDDING_DIM), +}); + +/** Cosine similarity of two equal-length vectors, in [-1, 1]; -1 if invalid. */ +export function cosineSimilarity(a: number[], b: number[]): number { + if (a.length === 0 || a.length !== b.length) return -1; + let dot = 0; + let normA = 0; + let normB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + if (normA === 0 || normB === 0) return -1; + return dot / (Math.sqrt(normA) * Math.sqrt(normB)); +} + +export interface FaceMatch { + match: boolean; + similarity: number; + threshold: number; +} + +/** Pure match decision for a stored/candidate embedding pair. */ +export function compareEmbeddings(stored: number[], candidate: number[]): FaceMatch { + const similarity = cosineSimilarity(stored, candidate); + return { + match: similarity >= FACE_MATCH_THRESHOLD, + similarity: Math.round(similarity * 1000) / 1000, + threshold: FACE_MATCH_THRESHOLD, + }; +} diff --git a/backend/functions/src/lib/face-token.test.ts b/backend/functions/src/lib/face-token.test.ts new file mode 100644 index 0000000..e58f09a --- /dev/null +++ b/backend/functions/src/lib/face-token.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { signFaceToken, verifyFaceToken, FACE_TOKEN_TTL_MS } from "./face-token"; +import { signKioskToken } from "../services/kiosk"; + +const SECRET = "test-secret"; +const EMPLOYEE = "01HZY8QK7M3N4P5R6S7T8V9W0X"; + +describe("face tokens", () => { + it("verifies a freshly issued token for the same employee", () => { + const token = signFaceToken(SECRET, EMPLOYEE); + expect(verifyFaceToken(SECRET, EMPLOYEE, token)).toBe(true); + }); + + it("rejects a token issued for a different employee", () => { + const token = signFaceToken(SECRET, "01HZY8QK7M3N4P5R6S7T8V9W0Y"); + expect(verifyFaceToken(SECRET, EMPLOYEE, token)).toBe(false); + }); + + it("rejects a token signed with a different secret", () => { + const token = signFaceToken("other-secret", EMPLOYEE); + expect(verifyFaceToken(SECRET, EMPLOYEE, token)).toBe(false); + }); + + it("rejects a token whose payload was tampered with", () => { + const now = Date.now(); + const token = signFaceToken(SECRET, EMPLOYEE, now); + const [, , sig] = token.split("."); + // Re-point a valid signature at a newer timestamp. + expect(verifyFaceToken(SECRET, EMPLOYEE, `${EMPLOYEE}.${now + 1}.${sig}`)).toBe(false); + }); + + it("expires after the TTL", () => { + const issuedAt = Date.now() - FACE_TOKEN_TTL_MS - 1; + const token = signFaceToken(SECRET, EMPLOYEE, issuedAt); + expect(verifyFaceToken(SECRET, EMPLOYEE, token)).toBe(false); + }); + + it("is still valid just inside the TTL", () => { + const issuedAt = Date.now() - (FACE_TOKEN_TTL_MS - 5_000); + const token = signFaceToken(SECRET, EMPLOYEE, issuedAt); + expect(verifyFaceToken(SECRET, EMPLOYEE, token)).toBe(true); + }); + + it("rejects a token issued implausibly far in the future", () => { + const token = signFaceToken(SECRET, EMPLOYEE, Date.now() + 10 * 60 * 1000); + expect(verifyFaceToken(SECRET, EMPLOYEE, token)).toBe(false); + }); + + it("rejects malformed tokens", () => { + for (const bad of ["", "x", `${EMPLOYEE}.123`, `${EMPLOYEE}.abc.def`, "a.b.c.d"]) { + expect(verifyFaceToken(SECRET, EMPLOYEE, bad)).toBe(false); + } + }); + + it("does not accept a kiosk token (domain separation)", () => { + const kiosk = signKioskToken(SECRET, EMPLOYEE); + expect(verifyFaceToken(SECRET, EMPLOYEE, kiosk)).toBe(false); + }); +}); diff --git a/backend/functions/src/lib/face-token.ts b/backend/functions/src/lib/face-token.ts new file mode 100644 index 0000000..2fcf3b2 --- /dev/null +++ b/backend/functions/src/lib/face-token.ts @@ -0,0 +1,79 @@ +import { createHmac, timingSafeEqual } from "crypto"; + +/** + * Face-verification tokens bind a successful `/attendance/face/verify` result + * to the punch that follows it. + * + * Without this the "verified" flag would be a client-set boolean: a tampered + * app could claim `faceVerified` on a punch no camera ever saw. Instead the + * server issues a signed, short-lived token on a real match, and only a punch + * carrying a valid token is recorded as face-verified. + * + * Format: `employeeId.issuedAtMs.signature`. Pure crypto, no Firebase imports, + * so it is unit-testable in isolation. + */ + +/** + * How long a verification stays usable. Generous enough to cover the capture → + * outbox → push path on a slow link, short enough to bound token reuse. + */ +export const FACE_TOKEN_TTL_MS = 10 * 60 * 1000; + +/** Tolerance for a client clock running ahead of the server. */ +const MAX_FUTURE_SKEW_MS = 60 * 1000; + +/** + * Derives a face-token key from the shared HMAC secret. Domain separation + * guarantees a kiosk token can never validate as a face token, and vice versa, + * without provisioning a second secret. + */ +function faceKey(secret: string): Buffer { + return createHmac("sha256", secret).update("worktrack.face.v1").digest(); +} + +function signature(secret: string, employeeId: string, issuedAt: number): string { + return createHmac("sha256", faceKey(secret)) + .update(`${employeeId}.${issuedAt}`) + .digest("hex"); +} + +export function signFaceToken( + secret: string, + employeeId: string, + issuedAt: number = Date.now(), +): string { + return `${employeeId}.${issuedAt}.${signature(secret, employeeId, issuedAt)}`; +} + +/** + * True when [token] is an authentic, unexpired token issued for [employeeId]. + * Fails closed on every malformed input. + */ +export function verifyFaceToken( + secret: string, + employeeId: string, + token: string, + now: number = Date.now(), +): boolean { + const parts = token.split("."); + if (parts.length !== 3) { + return false; + } + const [tokenEmployeeId, issuedAtRaw, provided] = parts; + const issuedAt = Number.parseInt(issuedAtRaw, 10); + if (!tokenEmployeeId || !provided || Number.isNaN(issuedAt)) { + return false; + } + // A token issued for someone else must never verify this employee's punch. + if (tokenEmployeeId !== employeeId) { + return false; + } + if (issuedAt > now + MAX_FUTURE_SKEW_MS || now - issuedAt > FACE_TOKEN_TTL_MS) { + return false; + } + const expected = signature(secret, tokenEmployeeId, issuedAt); + if (provided.length !== expected.length) { + return false; + } + return timingSafeEqual(Buffer.from(provided, "utf8"), Buffer.from(expected, "utf8")); +} diff --git a/backend/functions/src/lib/firestore.ts b/backend/functions/src/lib/firestore.ts new file mode 100644 index 0000000..dd4e1f1 --- /dev/null +++ b/backend/functions/src/lib/firestore.ts @@ -0,0 +1,110 @@ +import { getApps, initializeApp } from "firebase-admin/app"; +import { getFirestore, Timestamp } from "firebase-admin/firestore"; +import type { CollectionReference, Firestore } from "firebase-admin/firestore"; + +// Self-contained initialization keeps module import order irrelevant. +if (getApps().length === 0) { + initializeApp(); +} + +export const db: Firestore = getFirestore(); + +/** Sub-collections of companies/{cid}. Names match the client's ResourceTypes. */ +export type TenantCollection = + | "branches" + | "departments" + | "positions" + | "employees" + | "roleAssignments" + | "devices" + | "geofences" + | "shifts" + | "shiftAssignments" + | "punches" + | "attendanceDays" + | "regularizations" + | "leaveTypes" + | "leavePolicies" + | "leaveBalances" + | "leaveRequests" + // One working calendar per company, keyed by the Gregorian date, so saving + // the same day twice corrects it rather than double-counting. The original + // design allowed several named calendars per tenant; nothing has needed that, + // and a second calendar can be added later without moving these documents. + | "holidays" + | "salaryComponents" + // Money handed to somebody before payday, and taken back out of it. Kept as + // a principal plus a subcollection of repayments keyed by the payroll run + // that made them, because a run can be recomputed and a mutated balance + // would take the same month's money twice. See services/advanceStore.ts. + | "advances" + // What a piece-rate worker finished, and when. One document per entry rather + // than a running total per month, because a total nobody can break down is a + // total nobody can dispute — and disputes about piece counts are the whole + // reason a workshop keeps a book. See services/pieceWork.ts. + | "pieceRecords" + // Which of those components apply to one person, and at what amount. A + // component is a definition; this is the exception list against it — an + // allowance only some people get, a different figure for one of them, or a + // company-wide allowance withheld from one. Keyed `employeeId__componentId` + // so assigning twice corrects rather than duplicates. + | "employeeComponents" + | "salaryStructures" + | "employeeSalaries" + | "payrollRuns" + | "payslips" + | "expenses" + | "accounts" + | "journalEntries" + | "announcements" + // What the company is building, who is on which crew, and the individual + // pieces of work scheduled against a date. See services/work.ts. + | "projects" + | "projectTeams" + | "tasks" + // Tazkira, contract, work permit, health certificate — the papers a company + // has to hold for each person, and when each of them runs out. The entry + // existed and nothing used it; see services/employeeDocuments.ts. + | "documents" + | "auditLogs" + | "notifications" + | "idempotencyKeys"; + +export function tenant(cid: string, collection: TenantCollection): CollectionReference { + return db.collection("companies").doc(cid).collection(collection); +} + +/** ISO string for wire DTOs from a stored Firestore Timestamp. */ +export function toIso(value: Timestamp | undefined | null): string | null { + return value ? value.toDate().toISOString() : null; +} + +export function nowTimestamp(): Timestamp { + return Timestamp.now(); +} + +/** Appends an immutable audit log entry. Never awaited on the hot path fails soft. */ +export async function audit( + cid: string, + entry: { + actorId: string; + actorRole: string; + action: string; + resourceType: string; + resourceId: string; + before?: unknown; + after?: unknown; + }, +): Promise { + try { + await tenant(cid, "auditLogs").add({ + ...entry, + before: entry.before ?? null, + after: entry.after ?? null, + at: nowTimestamp(), + }); + } catch (e) { + // Audit failures must not fail the business operation, but they are loud. + console.error("AUDIT_WRITE_FAILED", { cid, action: entry.action, error: e }); + } +} diff --git a/backend/functions/src/lib/ids.ts b/backend/functions/src/lib/ids.ts new file mode 100644 index 0000000..88e7b4b --- /dev/null +++ b/backend/functions/src/lib/ids.ts @@ -0,0 +1,37 @@ +import { randomBytes } from "crypto"; + +const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/** + * ULID generator (Crockford base32, 48-bit time + 80-bit randomness) matching + * the Android client's implementation. IDs sort by creation time. + */ +export function ulid(timestamp: number = Date.now()): string { + const chars: string[] = new Array(26); + + let ts = timestamp; + for (let i = 9; i >= 0; i--) { + chars[i] = ENCODING[ts % 32]; + ts = Math.floor(ts / 32); + } + + const rnd = randomBytes(10); + let buffer = 0; + let bitsInBuffer = 0; + let out = 10; + for (const byte of rnd) { + buffer = (buffer << 8) | byte; + bitsInBuffer += 8; + while (bitsInBuffer >= 5) { + bitsInBuffer -= 5; + chars[out++] = ENCODING[(buffer >>> bitsInBuffer) & 0x1f]; + } + // Keep the working buffer within 32-bit int range. + buffer &= (1 << bitsInBuffer) - 1; + } + return chars.join(""); +} + +export function isValidUlid(value: string): boolean { + return /^[0-9A-HJKMNP-TV-Z]{26}$/.test(value.toUpperCase()); +} diff --git a/backend/functions/src/lib/shamsi.ts b/backend/functions/src/lib/shamsi.ts new file mode 100644 index 0000000..e2889a8 --- /dev/null +++ b/backend/functions/src/lib/shamsi.ts @@ -0,0 +1,124 @@ +/** + * Solar Hijri (هجری شمسی) helpers for payroll periods. Payroll runs are keyed by + * Shamsi year/month; this converts a Shamsi month to its Gregorian date range so + * attendance (stored as ISO dates) can be queried for that period. + * + * Integer division MUST truncate toward zero (jalaali algorithm) — never floor. + */ + +function div(a: number, b: number): number { + return Math.trunc(a / b); +} + +const BREAKS = [ + -61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, + 2262, 2324, 2394, 2456, 3178, +]; + +interface JalCal { + leap: number; + gy: number; + march: number; +} + +function jalCal(jy: number): JalCal { + const gy = jy + 621; + let leapJ = -14; + let jp = BREAKS[0]; + let jump = 0; + for (let i = 1; i < BREAKS.length; i++) { + const jm = BREAKS[i]; + jump = jm - jp; + if (jy < jm) break; + leapJ += div(jump, 33) * 8 + div(jump % 33, 4); + jp = jm; + } + let n = jy - jp; + leapJ += div(n, 33) * 8 + div((n % 33) + 3, 4); + if (jump % 33 === 4 && jump - n === 4) leapJ += 1; + const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150; + const march = 20 + leapJ - leapG; + if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33; + let leap = (((n + 1) % 33) - 1) % 4; + if (leap === -1) leap = 4; + return { leap, gy, march }; +} + +function g2d(gy: number, gm: number, gd: number): number { + let d = + div((gy + div(gm - 8, 6) + 100100) * 1461, 4) + + div(153 * ((gm + 9) % 12) + 2, 5) + + gd - + 34840408; + d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752; + return d; +} + +function d2g(jdn: number): { gy: number; gm: number; gd: number } { + let j = 4 * jdn + 139361631; + j += div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908; + const i = div(j % 1461, 4) * 5 + 308; + const gd = div(i % 153, 5) + 1; + const gm = (div(i, 153) % 12) + 1; + const gy = div(j, 1461) - 100100 + div(8 - gm, 6); + return { gy, gm, gd }; +} + +function j2d(jy: number, jm: number, jd: number): number { + const r = jalCal(jy); + return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1; +} + +function iso(gy: number, gm: number, gd: number): string { + return `${gy.toString().padStart(4, "0")}-${gm.toString().padStart(2, "0")}-${gd + .toString() + .padStart(2, "0")}`; +} + +export function isShamsiLeapYear(year: number): boolean { + return jalCal(year).leap === 0; +} + +export function shamsiMonthLength(year: number, month: number): number { + if (month <= 6) return 31; + if (month <= 11) return 30; + return isShamsiLeapYear(year) ? 30 : 29; +} + +/** ISO date of the 1st of a Shamsi month. */ +export function shamsiMonthStartIso(year: number, month: number): string { + const g = d2g(j2d(year, month, 1)); + return iso(g.gy, g.gm, g.gd); +} + +/** ISO date of the last day of a Shamsi month. */ +export function shamsiMonthEndIso(year: number, month: number): string { + const g = d2g(j2d(year, month, shamsiMonthLength(year, month))); + return iso(g.gy, g.gm, g.gd); +} + +/** Current Shamsi (year, month) for defaulting a payroll period. */ +export function currentShamsiMonth(): { year: number; month: number } { + const now = new Date(); + const jdn = g2d(now.getUTCFullYear(), now.getUTCMonth() + 1, now.getUTCDate()); + const gy = d2g(jdn).gy; + let jy = gy - 621; + const r = jalCal(jy); + const jdn1f = g2d(gy, 3, r.march); + let k = jdn - jdn1f; + let month: number; + if (k >= 0) { + if (k <= 185) { + month = 1 + div(k, 31); + } else { + k -= 186; + month = 7 + div(k, 30); + } + } else { + jy -= 1; + k += 179; + if (r.leap === 1) k += 1; + month = 7 + div(k, 30); + } + return { year: jy, month }; +} diff --git a/backend/functions/src/middleware/auth.ts b/backend/functions/src/middleware/auth.ts new file mode 100644 index 0000000..ace9db2 --- /dev/null +++ b/backend/functions/src/middleware/auth.ts @@ -0,0 +1,83 @@ +import type { NextFunction, Request, Response } from "express"; +import { getAuth } from "firebase-admin/auth"; +import { ApiError, ErrorCodes } from "../lib/errors"; + +/** Tenant/identity context resolved from verified Firebase custom claims. */ +export interface AuthContext { + uid: string; + companyId: string; + employeeId: string; + roles: string[]; + branchIds: string[]; +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + auth?: AuthContext; + } + } +} + +/** + * Verifies the bearer ID token and loads tenant context from custom claims + * ({ cid, eid, r, b }). Every /v1 route runs behind this — deny by default. + */ +export async function requireAuth( + req: Request, + _res: Response, + next: NextFunction, +): Promise { + try { + const header = req.header("Authorization") ?? ""; + const match = header.match(/^Bearer (.+)$/); + if (!match) { + throw ApiError.unauthenticated(); + } + + const decoded = await getAuth().verifyIdToken(match[1]).catch(() => { + throw ApiError.unauthenticated("Token is invalid or expired"); + }); + + const cid = decoded.cid as string | undefined; + const eid = decoded.eid as string | undefined; + if (!cid || !eid) { + // An account without tenant claims is not provisioned as an employee. + throw ApiError.permissionDenied("Account is not provisioned for any company"); + } + + // Self-signup company admins stay gated until they prove they own the + // address they signed up with — otherwise anyone could stand up a workspace + // under someone else's email. Accounts an admin creates for staff carry no + // `sv` claim, and neither does any account that existed before this shipped, + // so nobody in the field is affected. + if (decoded.sv === true && decoded.email_verified !== true) { + throw new ApiError( + 403, + ErrorCodes.EMAIL_NOT_VERIFIED, + "Verify your email address to finish setting up your company", + ); + } + + req.auth = { + uid: decoded.uid, + companyId: cid, + employeeId: eid, + roles: Array.isArray(decoded.r) ? (decoded.r as string[]) : [], + branchIds: Array.isArray(decoded.b) ? (decoded.b as string[]) : [], + }; + next(); + } catch (err) { + next(err); + } +} + +/** Non-null auth accessor for handlers running behind requireAuth. */ +export function authOf(req: Request): AuthContext { + const auth = req.auth; + if (!auth) { + throw ApiError.unauthenticated(); + } + return auth; +} diff --git a/backend/functions/src/middleware/deviceGuard.integration.test.ts b/backend/functions/src/middleware/deviceGuard.integration.test.ts new file mode 100644 index 0000000..e60727a --- /dev/null +++ b/backend/functions/src/middleware/deviceGuard.integration.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { Request, Response } from "express"; +import { db, tenant } from "../lib/firestore"; +import { ApiError } from "../lib/errors"; +import { clearDeviceGuardCache, enforceDeviceLicense } from "./deviceGuard"; +import { setLicense } from "../services/license"; + +/** + * Turning enforcement on must limit a company to the seats it bought — and must + * not lock out the phones that are already in employees' hands. + * + * The app in the field sends its device id on every request but has no way to + * enrol it, so a guard that demanded a pre-existing registration would 403 every + * employee of every paying company the moment the vendor issued a licence. These + * pin the difference between "unenrolled" (enrol it) and "over the limit" + * (refuse it), which is the only refusal the vendor actually sells. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); +let cid = ""; +let seq = 0; + +/** Minimal Express doubles: the guard only reads req.auth and two headers. */ +function call( + roles: string[], + headers: Record = {}, + employeeId = "emp_1", +): Promise { + const req = { + auth: { uid: employeeId, companyId: cid, employeeId, roles, branchIds: [] }, + header: (name: string) => headers[name], + } as unknown as Request; + + return new Promise((resolve) => { + enforceDeviceLicense(req, {} as Response, (err?: unknown) => + resolve((err as ApiError) ?? null), + ); + }); +} + +async function license(over: Record = {}): Promise { + await setLicense(cid, { + plan: "STANDARD", + deviceLimit: 2, + status: "ACTIVE", + expiresAt: null, + enforceDevices: true, + ...over, + } as Parameters[1]); +} + +async function seats(): Promise { + const snap = await tenant(cid, "devices").get(); + return snap.docs.filter((d) => d.data().status !== "REVOKED").map((d) => d.id); +} + +describe.skipIf(!EMULATOR)("device licence enforcement", () => { + beforeEach(async () => { + seq += 1; + cid = `guard_${Date.now()}_${seq}`; + await db.collection("companies").doc(cid).set({ name: "Guarded Co" }); + // The guard caches per company and per device for a minute; without this a + // later case inherits the previous one's answer. + clearDeviceGuardCache(); + }); + + it("lets a manager through — a browser is not a licensed device", async () => { + await license({ deviceLimit: 1 }); + await tenant(cid, "devices").doc("taken").set({ status: "ACTIVE", type: "MOBILE" }); + + expect(await call(["COMPANY_ADMIN"])).toBeNull(); + expect(await call(["HR_ADMIN"])).toBeNull(); + }); + + it("lets everyone through while the licence does not enforce", async () => { + await license({ enforceDevices: false, deviceLimit: 1 }); + + expect(await call(["EMPLOYEE"], { "X-Device-Id": "and-unknown" })).toBeNull(); + expect(await seats()).toEqual([]); // nothing enrolled either + }); + + it("enrols a phone it has never seen rather than refusing it", async () => { + // This is the case that would have bricked every fielded phone: the app + // sends an id, nothing ever registered it. + await license({ deviceLimit: 5 }); + + expect(await call(["EMPLOYEE"], { "X-Device-Id": "and-fielded-phone" })).toBeNull(); + expect(await seats()).toEqual(["and-fielded-phone"]); + }); + + it("records what the phone told us about itself", async () => { + await license(); + await call(["EMPLOYEE"], { + "X-Device-Id": "and-pixel", + "X-Device-Model": "Pixel 10", + "X-App-Version": "1.0.0", + }); + + const doc = (await tenant(cid, "devices").doc("and-pixel").get()).data()!; + expect(doc.model).toBe("Pixel 10"); + expect(doc.appVersion).toBe("1.0.0"); + expect(doc.employeeId).toBe("emp_1"); + }); + + it("refuses the phone that exceeds the seats the company bought", async () => { + await license({ deviceLimit: 2 }); + + expect(await call(["EMPLOYEE"], { "X-Device-Id": "and-1" }, "e1")).toBeNull(); + expect(await call(["EMPLOYEE"], { "X-Device-Id": "and-2" }, "e2")).toBeNull(); + + const third = await call(["EMPLOYEE"], { "X-Device-Id": "and-3" }, "e3"); + expect(third?.code).toBe("LICENSE_LIMIT_REACHED"); + expect(third?.status).toBe(403); + expect((await seats()).length).toBe(2); + }); + + it("keeps letting the phones that hold seats through once the licence is full", async () => { + // The company is at its limit; the people who already have the app must not + // start failing because a colleague was refused. + await license({ deviceLimit: 1 }); + expect(await call(["EMPLOYEE"], { "X-Device-Id": "and-1" }, "e1")).toBeNull(); + expect((await call(["EMPLOYEE"], { "X-Device-Id": "and-2" }, "e2"))?.code).toBe( + "LICENSE_LIMIT_REACHED", + ); + + clearDeviceGuardCache(); + expect(await call(["EMPLOYEE"], { "X-Device-Id": "and-1" }, "e1")).toBeNull(); + }); + + it("still refuses a phone an administrator revoked", async () => { + await license(); + await tenant(cid, "devices").doc("and-lost").set({ status: "REVOKED", type: "MOBILE" }); + + expect((await call(["EMPLOYEE"], { "X-Device-Id": "and-lost" }))?.code).toBe( + "DEVICE_REVOKED", + ); + }); + + it("finds a kiosk's seat from its login, since it sends no device header", async () => { + // createKioskAccount mints uid === kioskId === the device document id. The + // kiosk runs in a browser, so keying off X-Device-Id would refuse them all. + await license(); + await tenant(cid, "devices").doc("kiosk-abc").set({ status: "ACTIVE", type: "KIOSK" }); + + expect(await call(["KIOSK"], {}, "kiosk-abc")).toBeNull(); + }); + + it("refuses a kiosk that was revoked, and does not re-enrol it", async () => { + // Unlike a phone, an unknown kiosk is not "never enrolled" — the account and + // the device document are created together, so its absence is deliberate. + await license(); + await tenant(cid, "devices").doc("kiosk-old").set({ status: "REVOKED", type: "KIOSK" }); + + expect((await call(["KIOSK"], {}, "kiosk-old"))?.code).toBe("DEVICE_REVOKED"); + + clearDeviceGuardCache(); + expect((await call(["KIOSK"], {}, "kiosk-never"))?.code).toBe("DEVICE_REVOKED"); + expect(await seats()).toEqual([]); + }); + + it("refuses everything on an expired licence", async () => { + await license({ expiresAt: "2020-01-01" }); + + expect((await call(["EMPLOYEE"], { "X-Device-Id": "and-1" }))?.code).toBe( + "LICENSE_INACTIVE", + ); + // …and does not quietly hand out a seat on the way to refusing. + expect(await seats()).toEqual([]); + }); + + it("refuses everything on a suspended licence", async () => { + await license({ status: "SUSPENDED" }); + + expect((await call(["EMPLOYEE"], { "X-Device-Id": "and-1" }))?.code).toBe( + "LICENSE_INACTIVE", + ); + }); + + it("lets an employee into the portal without taking a seat", async () => { + // A browser has no device id and no way to get one. This used to 403 with + // "sign in again to activate this device" — advice a browser can never act + // on, and shown only at the companies that pay for enforcement. The licence + // counts devices running the app, and a browser is not one. + await license({ deviceLimit: 1 }); + + expect(await call(["EMPLOYEE"], {})).toBeNull(); + expect(await seats()).toEqual([]); // and it consumed nothing + }); + + it("still refuses a kiosk that is not a known device", async () => { + // The boundary that must not move with the line above: a kiosk also sends + // no header, but its login IS its device record, so an unknown one was + // revoked on purpose. + await license(); + + expect((await call(["KIOSK"], {}, "kiosk_gone"))?.code).toBe("DEVICE_REVOKED"); + }); + + it("still counts a phone that does send its device id", async () => { + // The exemption is for the absent header, not a weakening of the limit. + await license({ deviceLimit: 1 }); + + expect(await call(["EMPLOYEE"], { "X-Device-Id": "and-1" })).toBeNull(); + expect(await seats()).toEqual(["and-1"]); + + clearDeviceGuardCache(); + expect((await call(["EMPLOYEE"], { "X-Device-Id": "and-2" }, "emp_2"))?.code).toBe( + "LICENSE_LIMIT_REACHED", + ); + }); + + it("still refuses a browser session on a suspended licence", async () => { + // Exempt from the SEAT count, not from whether the licence is usable at all. + await license({ status: "SUSPENDED" }); + + expect((await call(["EMPLOYEE"], {}))?.code).toBe("LICENSE_INACTIVE"); + }); +}); diff --git a/backend/functions/src/middleware/deviceGuard.ts b/backend/functions/src/middleware/deviceGuard.ts new file mode 100644 index 0000000..79616d1 --- /dev/null +++ b/backend/functions/src/middleware/deviceGuard.ts @@ -0,0 +1,166 @@ +import type { NextFunction, Request, Response } from "express"; +import { ApiError, ErrorCodes } from "../lib/errors"; +import { tenant } from "../lib/firestore"; +import { activateDevice, getLicense, isDeviceActive, licenseUsable } from "../services/license"; +import { localDateOf } from "../services/attendance"; +import { getSettings } from "../services/settings"; +import { authOf } from "./auth"; + +/** + * Enforces per-device licensing on requests from the mobile app and kiosks. + * + * Two deliberate limits on the blast radius: + * + * - It only applies to companies that have turned `license.enforceDevices` on, + * which only the vendor can do. + * - It only applies to EMPLOYEE and KIOSK callers. Managers work in a browser, + * which is not a licensed device. + * + * It enrols rather than refuses. The app in the field already sends its device + * id on every request (AuthInterceptor) but has no way to enrol it, so a guard + * that demanded a pre-existing registration would lock out every phone of every + * paying company the instant enforcement went on. Instead an unknown phone + * claims a seat here, transactionally, and is refused only when the licence is + * genuinely full. That is the limit the vendor sells; being unenrolled is not. + * A kiosk is different: its login IS its device record, so an unknown kiosk was + * revoked on purpose and stays refused. + * + * Lookups are cached in-process for a minute, so the hot path costs roughly one + * read per device per minute per instance rather than one per request. The cost + * is that a revoked device keeps working for up to that long. + */ + +const CACHE_TTL_MS = 60_000; + +/** Roles that run on a licensed device rather than in a manager's browser. */ +const DEVICE_ROLES = new Set(["EMPLOYEE", "KIOSK"]); + +interface CacheEntry { + value: T; + expiresAt: number; +} + +const cache = new Map>(); + +async function cached(key: string, load: () => Promise): Promise { + const hit = cache.get(key); + const now = Date.now(); + if (hit && hit.expiresAt > now) return hit.value as T; + const value = await load(); + cache.set(key, { value, expiresAt: now + CACHE_TTL_MS }); + return value; +} + +/** Exposed for tests, which must not inherit a previous case's cached state. */ +export function clearDeviceGuardCache(): void { + cache.clear(); +} + +export async function enforceDeviceLicense( + req: Request, + _res: Response, + next: NextFunction, +): Promise { + try { + const auth = authOf(req); + if (!auth.roles.some((r) => DEVICE_ROLES.has(r))) { + next(); + return; + } + + const license = await cached(`lic:${auth.companyId}`, () => getLicense(auth.companyId)); + if (!license.enforceDevices) { + next(); + return; + } + + const settings = await cached(`set:${auth.companyId}`, () => getSettings(auth.companyId)); + const today = localDateOf(new Date(), settings.profile.timezone); + if (!licenseUsable(license, today)) { + throw new ApiError( + 403, + ErrorCodes.LICENSE_INACTIVE, + "This company's licence is not active", + ); + } + + // A kiosk IS its device: createKioskAccount mints the login with + // uid === kioskId === the device document id, so the seat is found from the + // token alone. The kiosk runs in a browser and sends no X-Device-Id header, + // so keying it off the header would refuse every kiosk on this planet. + const isKiosk = auth.roles.includes("KIOSK"); + const deviceId = isKiosk ? auth.employeeId : req.header("X-Device-Id"); + + if (!deviceId) { + // An employee in a browser, not on a phone. + // + // The licence counts DEVICES RUNNING THE APP. The app sends X-Device-Id + // on every request (AuthInterceptor); a browser has no device id to send + // and no way to obtain one, so a missing header here means "not a + // licensed device" rather than "an unactivated one". Refusing it would + // have shown an employee opening the portal a 403 on every page telling + // them to "sign in again to activate this device" — advice a browser can + // never act on, and only at companies that pay for enforcement. + // + // This is a licence boundary, not a security one. The browser session is + // still bound by RBAC, which for an EMPLOYEE is their own record and + // their own work; nothing here widens what they may read. + // + // Accepted consequence: somebody who repackaged the app to drop the + // header would not take a seat. Anyone able to rebuild and re-sign the + // APK is well past the point where a header check is what protects the + // agreement. + // + // Kiosks never reach this branch — a kiosk's device id is its own login + // (see above), so an unknown kiosk is still refused. + next(); + return; + } + + const active = await cached(`dev:${auth.companyId}:${deviceId}`, async () => { + const snap = await tenant(auth.companyId, "devices").doc(deviceId).get(); + return snap.exists && isDeviceActive(snap.data() as Record); + }); + + if (active) { + next(); + return; + } + + // Not registered. A kiosk that reaches here was revoked deliberately, so it + // stays refused. A phone, though, has simply never been seen: the app in the + // field sends its id on every request but has no way to enrol it. Refusing + // would lock out a company that is inside its seat count and has paid — + // so claim the seat now, and refuse only when there is genuinely none left. + if (isKiosk) { + throw new ApiError( + 403, + ErrorCodes.DEVICE_REVOKED, + "This device is not activated for this company", + ); + } + + // activateDevice does the seat count and the write in one transaction, so + // two phones enrolling at once cannot both take the last seat. It throws + // LICENSE_LIMIT_REACHED when the licence is full — which is the refusal the + // customer should see, and the one the vendor is actually selling. + await activateDevice( + auth.companyId, + auth.employeeId, + { + deviceId, + platform: "ANDROID", + model: req.header("X-Device-Model") ?? null, + appVersion: req.header("X-App-Version") ?? null, + }, + today, + ); + // The negative answer above is now stale; without this the device stays + // refused for up to a minute on this instance despite holding a seat. + cache.delete(`dev:${auth.companyId}:${deviceId}`); + + next(); + } catch (err) { + next(err); + } +} diff --git a/backend/functions/src/middleware/idempotency.test.ts b/backend/functions/src/middleware/idempotency.test.ts new file mode 100644 index 0000000..5aff55e --- /dev/null +++ b/backend/functions/src/middleware/idempotency.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Timestamp } from "firebase-admin/firestore"; +import { db, tenant } from "../lib/firestore"; +import { withIdempotency } from "./idempotency"; + +/** + * The old implementation read the key, ran the caller's work, then wrote the + * key. Two requests carrying the same Idempotency-Key both read "not found", + * both ran the work and both recorded it — the precise duplicate the header + * exists to prevent, and one the offline Android client's retries can reach. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); +let cid = ""; +let key = ""; +let seq = 0; + +function counter() { + const state = { runs: 0 }; + return { + state, + work: async () => { + state.runs += 1; + // Long enough for a sibling request to arrive mid-flight. + await new Promise((r) => setTimeout(r, 150)); + return { punchId: `p${state.runs}` }; + }, + }; +} + +describe.skipIf(!EMULATOR)("idempotency", () => { + beforeEach(async () => { + seq += 1; + cid = `idem_${Date.now()}_${seq}`; + key = `key_${seq}`; + await db.collection("companies").doc(cid).set({ name: "Idem" }); + }); + + it("runs the work and returns its result", async () => { + const { state, work } = counter(); + + const outcome = await withIdempotency(cid, key, work); + + expect(outcome.result).toEqual({ punchId: "p1" }); + expect(outcome.replayed).toBe(false); + expect(state.runs).toBe(1); + }); + + it("replays the stored response instead of running the work again", async () => { + const { state, work } = counter(); + await withIdempotency(cid, key, work); + + const replay = await withIdempotency(cid, key, work); + + expect(replay.replayed).toBe(true); + expect(replay.result).toEqual({ punchId: "p1" }); + expect(state.runs).toBe(1); + }); + + it("runs the work exactly once for two simultaneous requests", async () => { + const { state, work } = counter(); + + const results = await Promise.allSettled([ + withIdempotency(cid, key, work), + withIdempotency(cid, key, work), + ]); + + // The guarantee is that the punch happens once. The request that loses the + // claim either replays the winner's response or is refused with 409 — what + // it must never do is execute a second time. + expect(state.runs).toBe(1); + for (const r of results) { + if (r.status === "fulfilled") { + expect(r.value.result).toEqual({ punchId: "p1" }); + } else { + expect(r.reason).toMatchObject({ status: 409, code: "IDEMPOTENCY_REPLAY" }); + } + } + }); + + it("holds to one execution across a burst", async () => { + const { state, work } = counter(); + + // Eight callers contending on one claim document; transactions retry hard, + // which is slow but must never produce a second execution. + await Promise.allSettled( + Array.from({ length: 8 }, () => withIdempotency(cid, key, work)), + ); + + expect(state.runs).toBe(1); + }, 30_000); + + it("releases the key when the work fails, so a corrected retry can proceed", async () => { + await expect( + withIdempotency(cid, key, async () => { + throw new Error("geofence rejected"); + }), + ).rejects.toThrow("geofence rejected"); + + // The operation never happened, so the client must not be locked out of + // its own key for the whole lease. + const { state, work } = counter(); + const retry = await withIdempotency(cid, key, work); + + expect(retry.replayed).toBe(false); + expect(state.runs).toBe(1); + expect((await tenant(cid, "idempotencyKeys").doc(key).get()).data()?.status).toBe("COMPLETED"); + }); + + it("takes over a claim whose holder died", async () => { + // A request that crashed mid-flight leaves the key PENDING. Without a lease + // that key would be unusable forever. + await tenant(cid, "idempotencyKeys").doc(key).set({ + status: "PENDING", + claimedAt: Timestamp.fromMillis(Date.now() - 5 * 60 * 1000), + expiresAt: new Date(Date.now() + 60_000), + }); + + const { state, work } = counter(); + const outcome = await withIdempotency(cid, key, work); + + expect(outcome.replayed).toBe(false); + expect(state.runs).toBe(1); + }); + + it("runs every time when no key is supplied", async () => { + const { state, work } = counter(); + + await withIdempotency(cid, undefined, work); + await withIdempotency(cid, undefined, work); + + expect(state.runs).toBe(2); + }); +}); diff --git a/backend/functions/src/middleware/idempotency.ts b/backend/functions/src/middleware/idempotency.ts new file mode 100644 index 0000000..c1b3244 --- /dev/null +++ b/backend/functions/src/middleware/idempotency.ts @@ -0,0 +1,131 @@ +import { Timestamp } from "firebase-admin/firestore"; +import { ApiError, ErrorCodes } from "../lib/errors"; +import { db, nowTimestamp, tenant } from "../lib/firestore"; + +/** + * At-most-once execution for non-idempotent POSTs. + * + * This used to be a read, then the caller's work, then a write. Two requests + * carrying the same Idempotency-Key could both read "no such key", both run the + * work and both record it — which is exactly the case the header exists to + * prevent, and the offline Android client retries aggressively enough to hit + * it: a punch could be recorded twice, a payroll run computed twice. + * + * Now the key is *claimed* in a transaction before the work starts, so only one + * request can ever hold it. The claim carries a lease: if the request that held + * it died before finishing, a later attempt takes the claim over rather than + * being blocked on it forever. + * + * Keys are tenant-scoped and expire via a TTL policy on `expiresAt`. + */ + +/** How long a claim is honoured before another attempt may take it over. */ +const CLAIM_LEASE_MS = 60_000; + +const KEY_TTL_MS = 24 * 60 * 60 * 1000; + +interface StoredKey { + status: "PENDING" | "COMPLETED"; + response?: unknown; + claimedAt: Timestamp; +} + +type Claim = + | { kind: "claimed" } + | { kind: "replay"; response: unknown } + | { kind: "in_flight" }; + +export interface IdempotentOutcome { + result: T; + /** True when the stored response of an earlier identical request was returned. */ + replayed: boolean; +} + +/** + * Runs `work` at most once per (company, key). + * + * Without a key the work simply runs — the header is opt-in, and the Android + * client sends it for every queued mutation. + */ +export async function withIdempotency( + cid: string, + key: string | undefined, + work: () => Promise, +): Promise> { + if (!key) { + return { result: await work(), replayed: false }; + } + + const ref = tenant(cid, "idempotencyKeys").doc(key); + const claim = await claimKey(ref); + + if (claim.kind === "replay") { + return { result: claim.response as T, replayed: true }; + } + if (claim.kind === "in_flight") { + throw new ApiError( + 409, + ErrorCodes.IDEMPOTENCY_REPLAY, + "A request with this Idempotency-Key is already in progress", + ); + } + + let result: T; + try { + result = await work(); + } catch (err) { + // The work did not happen, so the key must not stay claimed — otherwise a + // client correcting and retrying the same request would be locked out of + // its own key for the whole lease. + await ref.delete().catch(() => undefined); + throw err; + } + + // The work HAS happened by this point. If recording the response fails, say + // so and still return it: turning a completed punch into an error because a + // bookkeeping write failed would be worse than the claim going stale. + await ref + .set({ + status: "COMPLETED", + response: result, + claimedAt: nowTimestamp(), + expiresAt: new Date(Date.now() + KEY_TTL_MS), + }) + .catch((err: unknown) => { + console.warn(`Could not record idempotency key ${key} for ${cid}`, err); + }); + + return { result, replayed: false }; +} + +/** Takes the key in a transaction, so exactly one caller can hold it. */ +async function claimKey(ref: FirebaseFirestore.DocumentReference): Promise { + const nowMs = Date.now(); + const fresh = { + status: "PENDING" as const, + claimedAt: nowTimestamp(), + expiresAt: new Date(nowMs + KEY_TTL_MS), + }; + + return db.runTransaction(async (tx) => { + const snap = await tx.get(ref); + if (!snap.exists) { + tx.set(ref, fresh); + return { kind: "claimed" }; + } + + const stored = snap.data() as StoredKey; + if (stored.status === "COMPLETED") { + return { kind: "replay", response: stored.response ?? null }; + } + + // Still PENDING. Either a sibling request is mid-flight, or the one that + // claimed it died and left the key held. + const heldFor = nowMs - (stored.claimedAt?.toMillis() ?? 0); + if (heldFor < CLAIM_LEASE_MS) { + return { kind: "in_flight" }; + } + tx.set(ref, fresh); + return { kind: "claimed" }; + }); +} diff --git a/backend/functions/src/middleware/rateLimit.test.ts b/backend/functions/src/middleware/rateLimit.test.ts new file mode 100644 index 0000000..563dd83 --- /dev/null +++ b/backend/functions/src/middleware/rateLimit.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { consumeRateLimit, type RateLimitRule } from "./rateLimit"; + +/** + * The counter has to be correct under concurrency: Cloud Functions runs many + * instances, so a read-then-write across two round trips would let simultaneous + * requests both see the same count and both pass. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); +const HOUR = 60 * 60 * 1000; +let key = ""; +let seq = 0; + +function rule(limit: number, bucket = "test"): RateLimitRule { + return { bucket, limit, windowMs: HOUR }; +} + +describe.skipIf(!EMULATOR)("rate limit", () => { + beforeEach(() => { + key = `k_${Date.now()}_${seq++}`; + }); + + it("allows up to the limit and refuses after it", async () => { + const r = rule(3); + const now = Date.now(); + + for (let i = 0; i < 3; i++) { + expect((await consumeRateLimit(r, key, now)).allowed, `call ${i + 1}`).toBe(true); + } + expect((await consumeRateLimit(r, key, now)).allowed).toBe(false); + }); + + it("reports how long the caller has to wait", async () => { + const r = rule(1); + const now = Date.now(); + await consumeRateLimit(r, key, now); + + const blocked = await consumeRateLimit(r, key, now + 10 * 60 * 1000); + + // Fifty minutes left of the hour, give or take the second it was called in. + expect(blocked.allowed).toBe(false); + expect(blocked.retryAfterSeconds).toBeGreaterThan(49 * 60); + expect(blocked.retryAfterSeconds).toBeLessThanOrEqual(50 * 60); + }); + + it("allows again once the window has passed", async () => { + const r = rule(1); + const now = Date.now(); + await consumeRateLimit(r, key, now); + + expect((await consumeRateLimit(r, key, now + HOUR + 1)).allowed).toBe(true); + }); + + it("counts each key separately", async () => { + const r = rule(1); + const now = Date.now(); + await consumeRateLimit(r, key, now); + + expect((await consumeRateLimit(r, `${key}_other`, now)).allowed).toBe(true); + }); + + it("does not let two buckets share a counter", async () => { + const now = Date.now(); + await consumeRateLimit(rule(1, "bucket-a"), key, now); + + expect((await consumeRateLimit(rule(1, "bucket-b"), key, now)).allowed).toBe(true); + }); + + it("lets only one of two simultaneous callers take the last slot", async () => { + const r = rule(1); + const now = Date.now(); + + const results = await Promise.all([ + consumeRateLimit(r, key, now), + consumeRateLimit(r, key, now), + ]); + + expect(results.filter((x) => x.allowed)).toHaveLength(1); + }); + + it("never allows more than the limit under a burst", async () => { + // Twenty callers contending on one counter is where a transaction can run + // out of retries. The guarantee that matters is that the limit is never + // exceeded — a contended caller is refused, never waved through. + const r = rule(5); + const now = Date.now(); + + const results = await Promise.all( + Array.from({ length: 20 }, () => consumeRateLimit(r, key, now)), + ); + + const allowed = results.filter((x) => x.allowed).length; + expect(allowed).toBeLessThanOrEqual(5); + expect(allowed).toBeGreaterThan(0); + }, 30_000); +}); diff --git a/backend/functions/src/middleware/rateLimit.ts b/backend/functions/src/middleware/rateLimit.ts new file mode 100644 index 0000000..751684e --- /dev/null +++ b/backend/functions/src/middleware/rateLimit.ts @@ -0,0 +1,119 @@ +import { createHash } from "node:crypto"; +import type { Request } from "express"; +import { ApiError } from "../lib/errors"; +import { db } from "../lib/firestore"; + +/** + * Fixed-window rate limiting backed by Firestore. + * + * Company signup is unauthenticated and writes about a dozen documents plus a + * Firebase Auth user per call, so an unthrottled endpoint is both a data- + * pollution and a billing problem. There is no shared process memory to count + * in — Cloud Functions scales to many instances — so the counter has to live in + * Firestore, and it has to be incremented inside a transaction or two + * simultaneous requests both read the same count and both pass. + * + * Counters live in a top-level `rateLimits` collection. Client SDKs cannot + * reach it (backend/firestore.rules denies everything), and `expiresAt` is + * shaped for a Firestore TTL policy so old windows delete themselves. + */ + +export interface RateLimitRule { + /** Namespace, so two limiters can never share a counter. */ + bucket: string; + limit: number; + windowMs: number; +} + +export interface RateLimitResult { + allowed: boolean; + retryAfterSeconds: number; +} + +interface WindowDoc { + count: number; + windowStart: number; + expiresAt: Date; +} + +/** Keys can be email addresses or IPs; hashing keeps them out of the store. */ +function counterId(bucket: string, key: string): string { + const digest = createHash("sha256").update(`${bucket}:${key}`).digest("hex"); + return `${bucket}_${digest.slice(0, 32)}`; +} + +export async function consumeRateLimit( + rule: RateLimitRule, + key: string, + now: number = Date.now(), +): Promise { + const ref = db.collection("rateLimits").doc(counterId(rule.bucket, key)); + + // Every caller of a given rule contends on one document, so under a burst the + // transaction can exhaust its retries. An abuse control that cannot count has + // to refuse rather than wave the request through, and turning that into a + // clean 429 is better than the 500 an escaping error would produce. + try { + return await runWindow(ref, rule, now); + } catch (err) { + console.warn(`Rate limit counter unavailable for ${rule.bucket}; failing closed`, err); + return { allowed: false, retryAfterSeconds: 60 }; + } +} + +function runWindow( + ref: FirebaseFirestore.DocumentReference, + rule: RateLimitRule, + now: number, +): Promise { + return db.runTransaction(async (tx) => { + const snap = await tx.get(ref); + const current = snap.data() as WindowDoc | undefined; + + const inWindow = current !== undefined && now - current.windowStart < rule.windowMs; + const windowStart = inWindow ? current.windowStart : now; + const used = inWindow ? current.count : 0; + const retryAfterSeconds = Math.max( + 1, + Math.ceil((windowStart + rule.windowMs - now) / 1000), + ); + + // Already over the limit: refuse without writing, so a flood of blocked + // requests does not itself turn into a flood of Firestore writes. + if (used >= rule.limit) { + return { allowed: false, retryAfterSeconds }; + } + + tx.set(ref, { + count: used + 1, + windowStart, + expiresAt: new Date(windowStart + rule.windowMs), + }); + return { allowed: true, retryAfterSeconds }; + }); +} + +/** + * Best-effort client address. + * + * X-Forwarded-For is caller-supplied and can be spoofed, so this is a way to + * spread honest traffic across counters — NOT a security boundary. The limits + * that actually bound abuse are the per-email one (an attacker targeting a + * specific victim cannot vary it) and the global one (nothing can bypass it). + */ +export function clientAddress(req: Request): string { + const forwarded = req.header("x-forwarded-for"); + const first = forwarded?.split(",")[0]?.trim(); + return first || req.socket.remoteAddress || "unknown"; +} + +/** Applies a rule and throws 429 with Retry-After when it is exhausted. */ +export async function enforceRateLimit( + rule: RateLimitRule, + key: string, + detail: string, +): Promise { + const { allowed, retryAfterSeconds } = await consumeRateLimit(rule, key); + if (allowed) return; + throw ApiError.rateLimited(detail, retryAfterSeconds); +} diff --git a/backend/functions/src/middleware/rbac.ts b/backend/functions/src/middleware/rbac.ts new file mode 100644 index 0000000..16b3dd7 --- /dev/null +++ b/backend/functions/src/middleware/rbac.ts @@ -0,0 +1,142 @@ +import type { NextFunction, Request, Response } from "express"; +import { ApiError } from "../lib/errors"; +import { authOf } from "./auth"; + +/** + * Permission catalog: role -> granted "resource:action" permissions. + * "*" grants everything (company scope). Enforcement is deny-by-default. + */ +const ROLE_PERMISSIONS: Record> = { + SUPER_ADMIN: new Set(["*"]), + COMPANY_ADMIN: new Set(["*"]), + HR_ADMIN: new Set([ + "employees:read", + "employees:write", + "attendance:read", + "attendance:write", + "attendance:approve", + "leave:read", + "leave:write", + "leave:approve", + "payroll:read", + "rosters:read", + "rosters:write", + "calendar:write", + "kiosk:issue", + "devices:read", + "devices:manage", + "announcements:read", + "announcements:write", + "work:read", + "work:write", + "self:tasks", + "audit:read", + ]), + PAYROLL_ADMIN: new Set([ + "employees:read", + "attendance:read", + "leave:read", + "payroll:read", + "payroll:run", + "payroll:approve", + ]), + // Dedicated finance & accounting admin: owns payroll, expenses, the general + // ledger and financial reporting. Read-only on the HR context it reports on. + FINANCE_ADMIN: new Set([ + "employees:read", + "attendance:read", + "leave:read", + "payroll:read", + "payroll:run", + "payroll:approve", + "finance:read", + "expenses:read", + "expenses:write", + "expenses:approve", + "ledger:read", + "ledger:write", + "audit:read", + ]), + BRANCH_MANAGER: new Set([ + "employees:read", + "attendance:read", + "attendance:approve", + "leave:read", + "leave:approve", + "rosters:read", + "rosters:write", + "kiosk:issue", + "devices:read", + "announcements:read", + "work:read", + "work:write", + "self:tasks", + ]), + // A team lead plans his own crew's day. This is the role the work-assignment + // feature is for: he is the person who knows what the site needs tomorrow. + TEAM_LEAD: new Set([ + "employees:read", + "attendance:read", + "leave:read", + "leave:approve", + "rosters:read", + "announcements:read", + "work:read", + "work:write", + "self:tasks", + ]), + EMPLOYEE: new Set([ + "self:punch", + "self:attendance", + "self:leave", + "self:payslips", + "announcements:read", + // Read his own assignments and report progress on them. NOT work:read: + // what the rest of the company is doing is not his to browse. + "self:tasks", + ]), + AUDITOR: new Set([ + "employees:read", + "attendance:read", + "leave:read", + "payroll:read", + "work:read", + "audit:read", + ]), + KIOSK: new Set(["kiosk:issue"]), +}; + +/** + * Whether these roles may decide any pending request, not only the ones routed + * to them. Mirrors the check inside decideLeaveRequest and decideRegularization + * — the approvals queue and the decision must agree on who may act, or the + * queue shows an empty list to somebody the server would happily let approve. + */ +export function canDecideAnyRequest(roles: string[]): boolean { + return roles.includes("HR_ADMIN") || roles.includes("COMPANY_ADMIN") || + roles.includes("SUPER_ADMIN"); +} + +export function hasPermission(roles: string[], permission: string): boolean { + return roles.some((role) => { + const granted = ROLE_PERMISSIONS[role]; + return granted !== undefined && (granted.has("*") || granted.has(permission)); + }); +} + +/** Express guard: 403 unless one of the caller's roles grants [permission]. */ +export function requirePermission(permission: string) { + return (req: Request, _res: Response, next: NextFunction): void => { + const auth = authOf(req); + if (!hasPermission(auth.roles, permission)) { + next(ApiError.permissionDenied(`Requires ${permission}`)); + return; + } + next(); + }; +} + +/** True when the caller may approve leave (any approver-capable role). */ +export function isApprover(roles: string[]): boolean { + return hasPermission(roles, "leave:approve"); +} diff --git a/backend/functions/src/middleware/validate.ts b/backend/functions/src/middleware/validate.ts new file mode 100644 index 0000000..11176a4 --- /dev/null +++ b/backend/functions/src/middleware/validate.ts @@ -0,0 +1,40 @@ +import type { Request } from "express"; +import type { ZodTypeAny, z } from "zod"; +import { ApiError } from "../lib/errors"; + +/** Parses and validates a request body; zod issues become 422 field errors. */ +export function parseBody(req: Request, schema: S): z.output { + const result = schema.safeParse(req.body); + if (!result.success) { + const fieldErrors: Record = {}; + for (const issue of result.error.issues) { + const path = issue.path.join(".") || "_root"; + if (!(path in fieldErrors)) { + fieldErrors[path] = issue.message; + } + } + throw ApiError.validation("Request body failed validation", fieldErrors); + } + return result.data; +} + +/** + * Same mapping for a payload that did not arrive as a request body — a sync + * outbox op, for instance. Sync applies each op independently, so a malformed + * one has to surface as a rejection for that op; letting a raw ZodError escape + * would fail the whole batch and the client would resend it forever. + */ +export function parsePayload(payload: unknown, schema: S): z.output { + const result = schema.safeParse(payload); + if (!result.success) { + const fieldErrors: Record = {}; + for (const issue of result.error.issues) { + const path = issue.path.join(".") || "_root"; + if (!(path in fieldErrors)) { + fieldErrors[path] = issue.message; + } + } + throw ApiError.validation("Payload failed validation", fieldErrors); + } + return result.data; +} diff --git a/backend/functions/src/middleware/vendor.test.ts b/backend/functions/src/middleware/vendor.test.ts new file mode 100644 index 0000000..9958081 --- /dev/null +++ b/backend/functions/src/middleware/vendor.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request, Response } from "express"; +import { ApiError } from "../lib/errors"; + +/** + * The vendor boundary. + * + * Every other route in this API takes the company id from the token. The vendor + * routes take it from the URL, so the only thing standing between a customer + * and every other customer's data is this middleware. These are the attempts to + * get past it. + */ + +const verify = vi.hoisted(() => ({ impl: async (_t: string) => ({}) as Record })); +vi.mock("firebase-admin/auth", () => ({ + getAuth: () => ({ verifyIdToken: (t: string) => verify.impl(t) }), +})); + +const { requireVendor } = await import("./vendor"); + +function call( + claims: Record | null, + header = "Bearer token", +): Promise<{ err: ApiError | null; vendor: unknown }> { + verify.impl = async () => { + if (!claims) throw new Error("bad token"); + return { uid: "u1", ...claims }; + }; + const req = { header: () => header, vendor: undefined } as unknown as Request; + return new Promise((resolve) => { + void requireVendor(req, {} as Response, (err?: unknown) => + resolve({ err: (err as ApiError) ?? null, vendor: (req as Request).vendor }), + ); + }); +} + +const VENDOR = { vendor: true, email: "staff@linumic.com", email_verified: true }; + +describe("the vendor boundary", () => { + beforeEach(() => { + verify.impl = async () => ({}); + }); + + it("lets verified vendor staff through", async () => { + const { err, vendor } = await call(VENDOR); + expect(err).toBeNull(); + expect(vendor).toEqual({ uid: "u1", email: "staff@linumic.com" }); + }); + + it("refuses a customer's company administrator", async () => { + // The most valuable token an attacker actually has. + const { err } = await call({ + cid: "acme", + eid: "emp_1", + r: ["COMPANY_ADMIN"], + email_verified: true, + }); + expect(err?.status).toBe(403); + }); + + it("refuses a token that merely claims a role named like ours", async () => { + const { err } = await call({ + cid: "acme", + eid: "emp_1", + r: ["SUPER_ADMIN", "VENDOR"], + email_verified: true, + }); + expect(err?.status).toBe(403); + }); + + it("refuses an account that is BOTH staff and an employee", async () => { + // A confused deputy: cross-tenant authority on an identity that also acts + // inside a company. grant-vendor.ts refuses to create one; this refuses to + // honour one however it came to exist. + const { err } = await call({ ...VENDOR, cid: "acme", eid: "emp_1" }); + expect(err?.status).toBe(403); + }); + + it("refuses a vendor claim that is a string rather than true", async () => { + // `"false"`, `"true"` and `1` are all truthy or coercible; the check is + // strict equality for exactly this reason. + for (const v of ["true", "false", 1, {}, [], "vendor"]) { + const { err } = await call({ vendor: v, email_verified: true }); + expect(err?.status, `vendor=${JSON.stringify(v)}`).toBe(403); + } + }); + + it("refuses staff who have not verified their address", async () => { + const { err } = await call({ ...VENDOR, email_verified: false }); + expect(err?.status).toBe(403); + }); + + it("refuses a token with no vendor claim at all", async () => { + const { err } = await call({ email_verified: true }); + expect(err?.status).toBe(403); + }); + + it("refuses an unsigned or expired token", async () => { + const { err } = await call(null); + expect(err?.status).toBe(401); + }); + + it("refuses a request with no Authorization header", async () => { + const { err } = await call(VENDOR, ""); + expect(err?.status).toBe(401); + }); + + it("refuses a header that is not a bearer token", async () => { + const { err } = await call(VENDOR, "Basic c3RhZmY6cGFzcw=="); + expect(err?.status).toBe(401); + }); + + it("does not tell a customer that this surface exists", async () => { + // A distinctive message would confirm there is a vendor console to attack. + const asCustomer = await call({ cid: "acme", eid: "e1", email_verified: true }); + const asNobody = await call({ email_verified: true }); + expect(asCustomer.err?.message).toBe(asNobody.err?.message); + }); +}); diff --git a/backend/functions/src/middleware/vendor.ts b/backend/functions/src/middleware/vendor.ts new file mode 100644 index 0000000..190df7a --- /dev/null +++ b/backend/functions/src/middleware/vendor.ts @@ -0,0 +1,96 @@ +import type { NextFunction, Request, Response } from "express"; +import { getAuth } from "firebase-admin/auth"; +import { ApiError } from "../lib/errors"; + +/** + * The vendor: Linumic staff, not any customer's employee. + * + * Everything else in this API takes the company id from the caller's token and + * never from the request, which is what makes one customer unable to read + * another's data. The vendor routes deliberately invert that — they take the + * company id from the URL — so the identity behind them has to be one that no + * customer can ever obtain. Two properties give that: + * + * 1. The `vendor` claim is set only by scripts/grant-vendor.ts, which needs + * credentials for the Firebase project itself. No signup, invite or + * employee route can write a custom claim at all, and the assignable-role + * list (services/invite.ts) contains no admin role of any kind. + * + * 2. A vendor account must carry NO tenant claims. An identity that is both + * would be a confused deputy: it could act on a company through the tenant + * routes while carrying cross-tenant authority. grant-vendor.ts refuses to + * create one and this middleware refuses to honour one. + * + * These routes are mounted outside requireAuth, which demands cid/eid — so a + * vendor token is rejected by every tenant route, and a tenant token is + * rejected here. The two identities cannot be used in each other's half of the + * product, in either direction. + */ + +export interface VendorContext { + uid: string; + email: string | null; +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + vendor?: VendorContext; + } + } +} + +export async function requireVendor( + req: Request, + _res: Response, + next: NextFunction, +): Promise { + try { + const header = req.header("Authorization") ?? ""; + const match = header.match(/^Bearer (.+)$/); + if (!match) { + throw ApiError.unauthenticated(); + } + + const decoded = await getAuth() + .verifyIdToken(match[1]) + .catch(() => { + throw ApiError.unauthenticated("Token is invalid or expired"); + }); + + if (decoded.vendor !== true) { + // Deliberately the same message a tenant user gets: whether this surface + // exists is not something a customer's token should be able to probe. + throw ApiError.permissionDenied("Not permitted"); + } + + // See (2) above. This is the check that keeps the inversion safe. + if (decoded.cid || decoded.eid) { + throw ApiError.permissionDenied("Not permitted"); + } + + // Staff sign in with a password like anyone else; an unverified address + // must not carry cross-tenant authority. + if (decoded.email_verified !== true) { + throw ApiError.permissionDenied("Verify your email address first"); + } + + req.vendor = { + uid: decoded.uid, + email: (decoded.email as string | undefined) ?? null, + }; + next(); + } catch (err) { + next(err); + } +} + +/** Non-null accessor for handlers running behind requireVendor. */ +export function vendorOf(req: Request): VendorContext { + const v = req.vendor; + if (!v) { + throw ApiError.unauthenticated(); + } + return v; +} diff --git a/backend/functions/src/routes/advances.ts b/backend/functions/src/routes/advances.ts new file mode 100644 index 0000000..3ebb6d7 --- /dev/null +++ b/backend/functions/src/routes/advances.ts @@ -0,0 +1,110 @@ +import { Router } from "express"; +import { z } from "zod"; +import { asyncHandler } from "../lib/errors"; +import { audit, tenant } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { parseBody } from "../middleware/validate"; +import { + advanceToDto, + cancelAdvance, + createAdvance, + type AdvanceDoc, +} from "../services/advanceStore"; + +/** + * Money handed to somebody before payday. + * + * Gated on payroll:run rather than payroll:read — recording an advance decides + * what comes out of a wage, so it belongs to whoever is trusted to run the + * payroll, not to everyone who may look at one. + */ +export const advancesRouter = Router(); + +const createSchema = z.object({ + employeeId: z.string().min(1), + // Positive: an advance of zero is not a transaction, and a negative one is + // somebody trying to express a bonus through the wrong form. + principal: z.number().positive().max(100_000_000), + /** Null takes it all at the next payroll, which is right for a small advance. */ + instalment: z.number().positive().max(100_000_000).nullish(), + issuedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + note: z.string().max(400).nullish(), +}); + +advancesRouter.get( + "/", + requirePermission("payroll:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const employeeId = req.query.employeeId ? String(req.query.employeeId) : null; + + let query = tenant(auth.companyId, "advances").orderBy("issuedOn", "desc").limit(200); + if (employeeId) { + query = tenant(auth.companyId, "advances") + .where("employeeId", "==", employeeId) + .orderBy("issuedOn", "desc") + .limit(200); + } + + const snap = await query.get(); + res.json({ + data: snap.docs.map((d) => advanceToDto(d.id, d.data() as AdvanceDoc)), + }); + }), +); + +advancesRouter.post( + "/", + requirePermission("payroll:run"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, createSchema); + + const { id, doc } = await createAdvance( + auth.companyId, + { + employeeId: payload.employeeId, + principal: payload.principal, + instalment: payload.instalment ?? null, + issuedOn: payload.issuedOn, + note: payload.note ?? null, + }, + auth.employeeId, + ); + + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "advances.create", + resourceType: "advances", + resourceId: id, + after: { employeeId: payload.employeeId, principal: payload.principal }, + }); + + res.status(201).json({ data: advanceToDto(id, doc) }); + }), +); + +/** + * Cancels an advance recorded in error. Not a delete: money handed over and + * then written off is precisely what an audit needs to still be able to see. + */ +advancesRouter.post( + "/:id/cancel", + requirePermission("payroll:run"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + await cancelAdvance(auth.companyId, req.params.id); + + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "advances.cancel", + resourceType: "advances", + resourceId: req.params.id, + }); + + res.json({ data: { id: req.params.id, status: "CANCELLED" } }); + }), +); diff --git a/backend/functions/src/routes/analytics.ts b/backend/functions/src/routes/analytics.ts new file mode 100644 index 0000000..03c3e0c --- /dev/null +++ b/backend/functions/src/routes/analytics.ts @@ -0,0 +1,122 @@ +import { Router } from "express"; +import { asyncHandler } from "../lib/errors"; +import { tenant } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { localDateOf } from "../services/attendance"; +import { getSettings } from "../services/settings"; + +export const analyticsRouter = Router(); + +/** + * Dashboard KPIs for a given day (defaults to today, server timezone). + * + * At small/medium tenant sizes this reads attendanceDays + counts directly. For + * 100k-employee tenants these figures are served from the BigQuery rollup + * instead (see docs/02); the response shape stays identical so the portal is + * unaffected by that swap. + */ +analyticsRouter.get( + "/kpis", + requirePermission("attendance:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + // Days are filed in the company's zone, so "today" must be resolved there. + // A UTC default puts the dashboard and the attendance board on different + // days for part of every evening. + const requestedDate = String(req.query.date ?? ""); + const date = /^\d{4}-\d{2}-\d{2}$/.test(requestedDate) + ? requestedDate + : localDateOf(new Date(), (await getSettings(auth.companyId)).profile.timezone); + + const [employeesSnap, daysSnap, pendingLeaveSnap] = await Promise.all([ + tenant(auth.companyId, "employees").where("status", "==", "ACTIVE").count().get(), + tenant(auth.companyId, "attendanceDays").where("date", "==", date).get(), + tenant(auth.companyId, "leaveRequests").where("status", "==", "PENDING").count().get(), + ]); + + const activeEmployees = employeesSnap.data().count; + + let present = 0; + let late = 0; + let onLeave = 0; + let halfDay = 0; + for (const doc of daysSnap.docs) { + const day = doc.data() as { status: string; lateMinutes?: number }; + switch (day.status) { + case "PRESENT": + present += 1; + if ((day.lateMinutes ?? 0) > 0) late += 1; + break; + case "HALF_DAY": + halfDay += 1; + break; + case "LEAVE": + onLeave += 1; + break; + default: + break; + } + } + const marked = present + halfDay + onLeave; + const absent = Math.max(0, activeEmployees - marked); + + res.json({ + data: { + date, + activeEmployees, + present, + halfDay, + late, + onLeave, + absent, + pendingLeaveRequests: pendingLeaveSnap.data().count, + attendanceRate: + activeEmployees > 0 ? Math.round(((present + halfDay) / activeEmployees) * 100) : 0, + }, + }); + }), +); + +/** + * 7-point attendance trend ending on `date` (present count per day). Powers the + * dashboard sparkline. Solar Hijri labels are formatted client-side. + */ +analyticsRouter.get( + "/attendance-trend", + requirePermission("attendance:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const requestedEnd = String(req.query.date ?? ""); + const endDate = /^\d{4}-\d{2}-\d{2}$/.test(requestedEnd) + ? requestedEnd + : localDateOf(new Date(), (await getSettings(auth.companyId)).profile.timezone); + + // Step whole days from midday, so the offset can never push a label onto + // the neighbouring date. + const anchor = new Date(`${endDate}T12:00:00Z`); + const dates: string[] = []; + for (let i = 6; i >= 0; i--) { + dates.push(new Date(anchor.getTime() - i * 86_400_000).toISOString().slice(0, 10)); + } + + const snap = await tenant(auth.companyId, "attendanceDays") + .where("date", ">=", dates[0]) + .where("date", "<=", dates[dates.length - 1]) + .get(); + + const presentByDate = new Map(dates.map((d) => [d, 0])); + for (const doc of snap.docs) { + const day = doc.data() as { date: string; status: string }; + if (day.status === "PRESENT" || day.status === "HALF_DAY") { + presentByDate.set(day.date, (presentByDate.get(day.date) ?? 0) + 1); + } + } + + res.json({ + data: { + points: dates.map((d) => ({ date: d, present: presentByDate.get(d) ?? 0 })), + }, + }); + }), +); diff --git a/backend/functions/src/routes/announcements.ts b/backend/functions/src/routes/announcements.ts new file mode 100644 index 0000000..c1613c3 --- /dev/null +++ b/backend/functions/src/routes/announcements.ts @@ -0,0 +1,92 @@ +import { Router } from "express"; +import { Timestamp } from "firebase-admin/firestore"; +import { z } from "zod"; +import { asyncHandler } from "../lib/errors"; +import { ulid } from "../lib/ids"; +import { audit, nowTimestamp, tenant, toIso } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { parseBody } from "../middleware/validate"; + +export const announcementsRouter = Router(); + +announcementsRouter.get( + "/", + requirePermission("announcements:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const snapshot = await tenant(auth.companyId, "announcements") + .where("publishedAt", "<=", nowTimestamp()) + .orderBy("publishedAt", "desc") + .limit(100) + .get(); + + const now = Date.now(); + res.json({ + data: snapshot.docs + .map((doc): Record => ({ id: doc.id, ...doc.data() })) + .filter((a) => { + const expires = a.expiresAt as Timestamp | null | undefined; + return !expires || expires.toMillis() > now; + }) + .map((a) => ({ + ...a, + publishedAt: toIso(a.publishedAt as Timestamp), + expiresAt: toIso((a.expiresAt as Timestamp | null | undefined) ?? null), + updatedAt: toIso(a.updatedAt as Timestamp), + })), + }); + }), +); + +const announcementCreateSchema = z.object({ + title: z.string().min(1).max(200), + body: z.string().min(1).max(5000), + priority: z.enum(["NORMAL", "IMPORTANT", "URGENT"]).default("NORMAL"), + publishAt: z.string().datetime().nullish(), + expiresAt: z.string().datetime().nullish(), +}); + +announcementsRouter.post( + "/", + requirePermission("announcements:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, announcementCreateSchema); + + const id = ulid(); + const now = nowTimestamp(); + const doc = { + companyId: auth.companyId, + title: payload.title, + body: payload.body, + priority: payload.priority, + publishedAt: payload.publishAt + ? Timestamp.fromDate(new Date(payload.publishAt)) + : now, + expiresAt: payload.expiresAt ? Timestamp.fromDate(new Date(payload.expiresAt)) : null, + createdBy: auth.employeeId, + createdByName: null, + updatedAt: now, + }; + await tenant(auth.companyId, "announcements").doc(id).create(doc); + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "announcements.create", + resourceType: "announcements", + resourceId: id, + after: { title: payload.title, priority: payload.priority }, + }); + + res.status(201).json({ + data: { + id, + ...doc, + publishedAt: toIso(doc.publishedAt), + expiresAt: toIso(doc.expiresAt), + updatedAt: toIso(doc.updatedAt), + }, + }); + }), +); diff --git a/backend/functions/src/routes/attendance.ts b/backend/functions/src/routes/attendance.ts new file mode 100644 index 0000000..ac3053a --- /dev/null +++ b/backend/functions/src/routes/attendance.ts @@ -0,0 +1,445 @@ +import { Router } from "express"; +import { Timestamp } from "firebase-admin/firestore"; +import { asyncHandler, ApiError } from "../lib/errors"; +import { db, tenant, toIso } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { hasPermission, requirePermission } from "../middleware/rbac"; +import { withIdempotency } from "../middleware/idempotency"; +import { parseBody } from "../middleware/validate"; +import { applyPunch, punchCreateSchema } from "../services/punch"; +import { embeddingSchema, verifyFace } from "../services/face"; +import { signFaceToken } from "../lib/face-token"; +import { localDateOf, weekOf } from "../services/attendance"; +import { getSettings } from "../services/settings"; +import { classifyDay, listHolidays } from "../services/calendar"; +import { + createRegularization, + decideRegularization, + regularizationCreateSchema, + regularizationDecisionSchema, + listRegularizations, +} from "../services/regularization"; +import { kioskSecret } from "../config"; + +export const attendanceRouter = Router(); + +/** + * Which branch the caller is allowed to look at. + * + * A requested branchId used to win outright, so a BRANCH_MANAGER could read any + * other branch's board simply by passing its id. It is now honoured only for + * company-wide roles or a branch the caller actually belongs to; anything else + * is refused rather than quietly narrowed, so the caller learns it was denied. + */ +function resolveBranchScope( + auth: { roles: string[]; branchIds: string[] }, + requested: string | null, +): string | null { + const companyWide = + auth.roles.includes("COMPANY_ADMIN") || + auth.roles.includes("HR_ADMIN") || + auth.roles.includes("AUDITOR") || + auth.roles.includes("SUPER_ADMIN"); + + if (companyWide) { + return requested; + } + if (requested !== null) { + if (!auth.branchIds.includes(requested)) { + throw ApiError.permissionDenied("Not a member of that branch"); + } + return requested; + } + return auth.branchIds[0] ?? null; +} + + +/** + * Verify a face check-in: the app sends the on-device embedding of the person + * at the camera; the server compares it to the caller's enrolled embedding. + * + * On a match the response carries a short-lived signed token. The punch that + * follows must present that token to be recorded as face-verified — the client + * cannot assert verification on its own. + */ +attendanceRouter.post( + "/face/verify", + requirePermission("self:punch"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const { embedding } = parseBody(req, embeddingSchema); + const result = await verifyFace(auth.companyId, auth.employeeId, embedding); + const token = result.match + ? signFaceToken(kioskSecret.value(), auth.employeeId) + : null; + res.json({ data: { ...result, token } }); + }), +); + +/** Direct online punch (web/kiosk clients; Android normally uses /sync/push). */ +attendanceRouter.post( + "/punches", + requirePermission("self:punch"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, punchCreateSchema); + + const { result: dto, replayed } = await withIdempotency( + auth.companyId, + req.header("Idempotency-Key"), + () => applyPunch(auth.companyId, auth.employeeId, payload, kioskSecret.value()), + ); + + res.status(replayed ? 200 : 201).json({ data: dto }); + }), +); + +// -------------------------------------------------------- regularizations + +/** Employee files a correction request for a day (online path; app uses sync). */ +attendanceRouter.post( + "/regularizations", + requirePermission("self:attendance"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, regularizationCreateSchema); + const dto = await createRegularization(auth.companyId, auth.employeeId, payload); + res.status(201).json({ data: dto }); + }), +); + +/** scope=mine (default) or scope=approvals (waiting on the caller). */ +attendanceRouter.get( + "/regularizations", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const scope = String(req.query.scope ?? "mine"); + if (scope === "approvals" && !hasPermission(auth.roles, "attendance:approve")) { + throw ApiError.permissionDenied("Requires attendance:approve"); + } + res.json({ + data: await listRegularizations(auth.companyId, auth.employeeId, auth.roles, scope), + }); + }), +); + +attendanceRouter.post( + "/regularizations/:id/decide", + asyncHandler(async (req, res) => { + const auth = authOf(req); + if (!hasPermission(auth.roles, "attendance:approve")) { + throw ApiError.permissionDenied("Requires attendance:approve"); + } + const payload = parseBody(req, regularizationDecisionSchema); + const dto = await decideRegularization( + auth.companyId, + req.params.id, + auth.employeeId, + auth.roles, + payload.decision, + payload.note ?? null, + ); + res.json({ data: dto }); + }), +); + +/** + * Manager live board: every employee's attendance status for one day, joined + * with employee name/branch. Branch managers are scoped to their branch(es). + */ +attendanceRouter.get( + "/overview", + requirePermission("attendance:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + // Default to the company's calendar day, not UTC: days are filed in the + // company zone, so a UTC default silently asks for the wrong one. + const settings = await getSettings(auth.companyId); + const requestedDate = String(req.query.date ?? ""); + const date = /^\d{4}-\d{2}-\d{2}$/.test(requestedDate) + ? requestedDate + : localDateOf(new Date(), settings.profile.timezone); + + // What kind of day this is. Without it a Friday and a public holiday look + // exactly like a day the whole company failed to turn up: every row reads + // ABSENT and nothing on the board says why. + const holidays = await listHolidays(auth.companyId, date, date); + const dayKind = classifyDay( + date, + settings.policies.weekendDays, + new Set(holidays.map((h) => h.date)), + ); + + const branchFilter = resolveBranchScope( + auth, + req.query.branchId ? String(req.query.branchId) : null, + ); + + let employeesQuery = tenant(auth.companyId, "employees").where("status", "==", "ACTIVE"); + if (branchFilter) { + employeesQuery = employeesQuery.where("branchId", "==", branchFilter); + } + const [employeesSnap, daysSnap] = await Promise.all([ + employeesQuery.limit(500).get(), + tenant(auth.companyId, "attendanceDays").where("date", "==", date).get(), + ]); + + const dayByEmployee = new Map>(); + for (const doc of daysSnap.docs) { + const day = doc.data() as { employeeId: string }; + dayByEmployee.set(day.employeeId, day as Record); + } + + interface EmployeeRow { + firstName: string; + lastName: string; + branchId?: string | null; + status?: string; + } + const employeeDocs = new Map(); + for (const doc of employeesSnap.docs) { + employeeDocs.set(doc.id, doc.data() as EmployeeRow); + } + + // Someone who clocked in must never be invisible here. Employees who are no + // longer ACTIVE (left, suspended, still onboarding) are excluded from the + // roster query above, so pull in any of them that actually have a day + // record — otherwise their attendance silently vanishes from the board. + const missingIds = [...dayByEmployee.keys()].filter((id) => !employeeDocs.has(id)); + if (missingIds.length > 0) { + const refs = missingIds + .slice(0, 200) + .map((id) => tenant(auth.companyId, "employees").doc(id)); + const extra = await db.getAll(...refs); + for (const doc of extra) { + if (!doc.exists) continue; + const emp = doc.data() as EmployeeRow; + // Branch managers stay scoped to their own branch. + if (branchFilter && (emp.branchId ?? null) !== branchFilter) continue; + employeeDocs.set(doc.id, emp); + } + } + + const rows = [...employeeDocs.entries()].map(([employeeId, emp]) => { + const day = dayByEmployee.get(employeeId); + return { + employeeId, + employeeName: `${emp.firstName} ${emp.lastName}`.trim(), + branchId: emp.branchId ?? null, + employeeStatus: emp.status ?? "ACTIVE", + status: (day?.status as string | undefined) ?? "ABSENT", + firstInAt: toIso((day?.firstInAt as Timestamp | undefined) ?? null), + lastOutAt: toIso((day?.lastOutAt as Timestamp | undefined) ?? null), + workedMinutes: (day?.workedMinutes as number | undefined) ?? 0, + lateMinutes: (day?.lateMinutes as number | undefined) ?? 0, + // The photo itself is deliberately NOT sent here. This board carries up + // to 500 rows and the portal refreshes it every minute; a ~200 KB base64 + // selfie per row made the response tens of megabytes. The flag lets the + // portal show a thumbnail affordance and fetch the image on demand. + hasCheckInSelfie: Boolean(day?.checkInSelfie), + checkInFaceVerified: (day?.checkInFaceVerified as boolean | undefined) ?? false, + needsReview: (day?.needsReview as boolean | undefined) ?? false, + // Punches the server refused, so the portal can explain an empty day. + rejectedCount: (day?.rejectedCount as number | undefined) ?? 0, + rejectedReason: (day?.rejectedReason as string | undefined) ?? null, + rejectedAt: toIso((day?.rejectedAt as Timestamp | undefined) ?? null), + }; + }); + + res.json({ + data: { + date, + dayKind, + holidayName: holidays[0]?.name ?? null, + rows, + }, + }); + }), +); + +/** + * One week of attendance for the whole team, for the manager's weekly review. + * + * The daily board answers "who is in right now"; this answers "how did the week + * go" — which needs the days side by side rather than one at a time. Weeks run + * Saturday to Friday, the Afghan working week, and dates are resolved in the + * company timezone like every other attendance date. + */ +attendanceRouter.get( + "/weekly", + requirePermission("attendance:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const settings = await getSettings(auth.companyId); + const timezone = settings.profile.timezone; + + const anchor = /^\d{4}-\d{2}-\d{2}$/.test(String(req.query.date ?? "")) + ? String(req.query.date) + : localDateOf(new Date(), timezone); + const dates = weekOf(anchor); + + const branchFilter = resolveBranchScope( + auth, + req.query.branchId ? String(req.query.branchId) : null, + ); + + let employeesQuery = tenant(auth.companyId, "employees").where("status", "==", "ACTIVE"); + if (branchFilter) { + employeesQuery = employeesQuery.where("branchId", "==", branchFilter); + } + + interface DayDoc { + employeeId: string; + date: string; + status?: string; + workedMinutes?: number; + lateMinutes?: number; + needsReview?: boolean; + rejectedCount?: number; + } + + const [employeesSnap, daysSnap] = await Promise.all([ + employeesQuery.limit(500).get(), + tenant(auth.companyId, "attendanceDays") + .where("date", ">=", dates[0]) + .where("date", "<=", dates[dates.length - 1]) + .get(), + ]); + + // employeeId -> date -> day + const byEmployee = new Map>(); + for (const doc of daysSnap.docs) { + const day = doc.data() as DayDoc; + const forEmployee = byEmployee.get(day.employeeId) ?? new Map(); + forEmployee.set(day.date, day); + byEmployee.set(day.employeeId, forEmployee); + } + + interface EmployeeRow { + firstName: string; + lastName: string; + branchId?: string | null; + status?: string; + } + const employees = new Map(); + for (const doc of employeesSnap.docs) { + employees.set(doc.id, doc.data() as EmployeeRow); + } + + // Same rule as the daily board: someone who worked is never invisible, + // even if they have since left or are not yet activated. + const missingIds = [...byEmployee.keys()].filter((id) => !employees.has(id)); + if (missingIds.length > 0) { + const extra = await db.getAll( + ...missingIds.slice(0, 200).map((id) => tenant(auth.companyId, "employees").doc(id)), + ); + for (const doc of extra) { + if (!doc.exists) continue; + const emp = doc.data() as EmployeeRow; + if (branchFilter && (emp.branchId ?? null) !== branchFilter) continue; + employees.set(doc.id, emp); + } + } + + const rows = [...employees.entries()].map(([employeeId, emp]) => { + const forEmployee = byEmployee.get(employeeId); + const days = dates.map((date) => { + const day = forEmployee?.get(date); + return { + date, + status: day?.status ?? "ABSENT", + workedMinutes: day?.workedMinutes ?? 0, + lateMinutes: day?.lateMinutes ?? 0, + needsReview: day?.needsReview ?? false, + rejectedCount: day?.rejectedCount ?? 0, + }; + }); + return { + employeeId, + employeeName: `${emp.firstName} ${emp.lastName}`.trim(), + employeeStatus: emp.status ?? "ACTIVE", + branchId: emp.branchId ?? null, + days, + totalWorkedMinutes: days.reduce((sum, d) => sum + d.workedMinutes, 0), + presentDays: days.filter((d) => d.status === "PRESENT" || d.status === "HALF_DAY").length, + lateDays: days.filter((d) => d.lateMinutes > 0).length, + needsReviewDays: days.filter((d) => d.needsReview || d.rejectedCount > 0).length, + }; + }); + + rows.sort((a, b) => a.employeeName.localeCompare(b.employeeName)); + res.json({ data: { from: dates[0], to: dates[dates.length - 1], dates, rows } }); + }), +); + +/** + * The check-in photo for one day, fetched only when a manager opens it. + * + * The board deliberately carries a boolean instead of the image; at 500 rows + * refreshed every minute, inlining base64 photos made the response unusable. + */ +attendanceRouter.get( + "/days/:employeeId/:date/selfie", + requirePermission("attendance:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const { employeeId, date } = req.params; + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + throw ApiError.validation("date must be YYYY-MM-DD"); + } + const snap = await tenant(auth.companyId, "attendanceDays") + .doc(`${employeeId}_${date}`) + .get(); + const selfie = (snap.data() as { checkInSelfie?: string } | undefined)?.checkInSelfie; + if (!selfie) { + throw ApiError.notFound("No check-in photo for that day"); + } + res.json({ data: { selfie } }); + }), +); + +/** Attendance day projections for a date window (self, or any employee with attendance:read). */ +attendanceRouter.get( + "/days", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const from = String(req.query.from ?? ""); + const to = String(req.query.to ?? ""); + if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) { + throw ApiError.validation("from/to must be ISO dates (YYYY-MM-DD)"); + } + + const requested = String(req.query.employeeId ?? auth.employeeId); + if (requested !== auth.employeeId && !hasPermission(auth.roles, "attendance:read")) { + throw ApiError.permissionDenied("Requires attendance:read for other employees"); + } + + const snapshot = await tenant(auth.companyId, "attendanceDays") + .where("employeeId", "==", requested) + .where("date", ">=", from) + .where("date", "<=", to) + .orderBy("date", "desc") + .limit(400) + .get(); + + res.json({ + data: snapshot.docs.map((doc) => { + const d = doc.data(); + return { + id: doc.id, + employeeId: d.employeeId, + date: d.date, + shiftId: d.shiftId ?? null, + firstInAt: toIso(d.firstInAt as Timestamp | null), + lastOutAt: toIso(d.lastOutAt as Timestamp | null), + workedMinutes: d.workedMinutes ?? 0, + lateMinutes: d.lateMinutes ?? 0, + earlyOutMinutes: d.earlyOutMinutes ?? 0, + overtimeMinutes: d.overtimeMinutes ?? 0, + status: d.status ?? "PENDING", + updatedAt: toIso(d.updatedAt as Timestamp | null), + }; + }), + }); + }), +); diff --git a/backend/functions/src/routes/calendar.ts b/backend/functions/src/routes/calendar.ts new file mode 100644 index 0000000..4c6034a --- /dev/null +++ b/backend/functions/src/routes/calendar.ts @@ -0,0 +1,141 @@ +import { Router } from "express"; +import { z } from "zod"; +import { asyncHandler } from "../lib/errors"; +import { audit } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { parseBody } from "../middleware/validate"; +import { + classifyDay, + deleteHoliday, + eachDate, + holidayWriteSchema, + listHolidays, + saveHoliday, + seedSolarHolidays, +} from "../services/calendar"; +import { getSettings } from "../services/settings"; + +export const calendarRouter = Router(); + +const rangeSchema = z.object({ + from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), +}); + +/** + * The company's holidays. Readable by anyone signed in — an employee needs to + * know the office is closed just as much as the manager who closed it. + */ +calendarRouter.get( + "/holidays", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const from = typeof req.query.from === "string" ? req.query.from : undefined; + const to = typeof req.query.to === "string" ? req.query.to : undefined; + res.json({ data: await listHolidays(auth.companyId, from, to) }); + }), +); + +/** + * Every date in a range with what kind of day it is. This is what makes a quiet + * Friday distinguishable from an office where nobody showed up. + */ +calendarRouter.get( + "/days", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const { from, to } = rangeSchema.parse({ from: req.query.from, to: req.query.to }); + + const [settings, holidays] = await Promise.all([ + getSettings(auth.companyId), + listHolidays(auth.companyId, from, to), + ]); + const byDate = new Map(holidays.map((h) => [h.date, h])); + const dates = new Set(holidays.map((h) => h.date)); + + res.json({ + data: eachDate(from, to).map((date) => { + const kind = classifyDay(date, settings.policies.weekendDays, dates); + const holiday = kind === "HOLIDAY" ? byDate.get(date) : undefined; + return { + date, + kind, + holidayName: holiday?.name ?? null, + holidayNameEn: holiday?.nameEn ?? null, + paid: holiday?.paid ?? null, + }; + }), + }); + }), +); + +calendarRouter.put( + "/holidays/:date", + requirePermission("calendar:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + // The path carries the date; the body may repeat it but the path wins. + const payload = parseBody(req, holidayWriteSchema.extend({ date: z.string().optional() })); + const holiday = await saveHoliday(auth.companyId, { + ...payload, + date: req.params.date, + }); + + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "calendar.holiday.save", + resourceType: "holidays", + resourceId: holiday.date, + after: { name: holiday.name, paid: holiday.paid }, + }); + + res.json({ data: holiday }); + }), +); + +calendarRouter.delete( + "/holidays/:date", + requirePermission("calendar:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + await deleteHoliday(auth.companyId, req.params.date); + + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "calendar.holiday.delete", + resourceType: "holidays", + resourceId: req.params.date, + }); + + res.status(204).end(); + }), +); + +/** + * Generates the Solar Hijri holidays for a year. Only the fixed ones — the + * religious holidays follow the moon and are announced days ahead, so they are + * entered by hand rather than guessed at. + */ +calendarRouter.post( + "/holidays/seed", + requirePermission("calendar:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const { year } = parseBody(req, z.object({ year: z.number().int().min(1300).max(1500) })); + const added = await seedSolarHolidays(auth.companyId, year); + + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "calendar.holiday.seed", + resourceType: "holidays", + resourceId: String(year), + after: { added }, + }); + + res.json({ data: { year, added } }); + }), +); diff --git a/backend/functions/src/routes/company.ts b/backend/functions/src/routes/company.ts new file mode 100644 index 0000000..9386445 --- /dev/null +++ b/backend/functions/src/routes/company.ts @@ -0,0 +1,69 @@ +import { Router } from "express"; +import { asyncHandler } from "../lib/errors"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { parseBody } from "../middleware/validate"; +import { localDateOf } from "../services/attendance"; +import { + cancelDeletion, + deletionRequestSchema, + getDeletion, + GRACE_DAYS, + requestDeletion, +} from "../services/companyDeletion"; +import { getSettings } from "../services/settings"; + +export const companyRouter = Router(); + +/** + * Closing the company account. + * + * Gated on company:delete, which only a COMPANY_ADMIN holds through the admin + * wildcard — HR and finance administrators run the company day to day but do + * not get to end it. + */ +companyRouter.get( + "/deletion", + requirePermission("company:delete"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const deletion = await getDeletion(auth.companyId); + res.json({ data: { ...deletion, graceDays: GRACE_DAYS } }); + }), +); + +companyRouter.post( + "/deletion", + requirePermission("company:delete"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, deletionRequestSchema); + // Dated in the company's own zone, so the grace period is counted in the + // days the company actually lives in. + const settings = await getSettings(auth.companyId); + const today = localDateOf(new Date(), settings.profile.timezone); + + const deletion = await requestDeletion( + auth.companyId, + auth.employeeId, + auth.roles.join(","), + payload, + today, + ); + res.status(202).json({ data: { ...deletion, graceDays: GRACE_DAYS } }); + }), +); + +companyRouter.delete( + "/deletion", + requirePermission("company:delete"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const deletion = await cancelDeletion( + auth.companyId, + auth.employeeId, + auth.roles.join(","), + ); + res.json({ data: { ...deletion, graceDays: GRACE_DAYS } }); + }), +); diff --git a/backend/functions/src/routes/crm.integration.test.ts b/backend/functions/src/routes/crm.integration.test.ts new file mode 100644 index 0000000..fa91b17 --- /dev/null +++ b/backend/functions/src/routes/crm.integration.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { db } from "../lib/firestore"; + +/** + * The vendor's CRM, through the real app. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); + +const token = vi.hoisted(() => ({ claims: {} as Record })); +vi.mock("firebase-admin/auth", async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + getAuth: () => ({ + verifyIdToken: async () => { + if (!token.claims.uid) throw new Error("no token"); + return token.claims; + }, + }), + }; +}); + +const { createApp } = await import("../app"); +const app = createApp(); + +async function request( + method: string, + path: string, + body?: unknown, +): Promise<{ status: number; body: Record }> { + const { createServer } = await import("node:http"); + const server = createServer(app); + await new Promise((r) => server.listen(0, r)); + const port = (server.address() as { port: number }).port; + try { + const res = await fetch(`http://127.0.0.1:${port}${path}`, { + method, + headers: { Authorization: "Bearer t", "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : {} }; + } finally { + server.close(); + } +} + +const VENDOR = { uid: "vendor_1", vendor: true, email: "staff@linumic.com", email_verified: true }; +const CUSTOMER = { uid: "u1", cid: "acme", eid: "e1", r: ["COMPANY_ADMIN"], email_verified: true }; + +const day = (n: number) => + new Date(Date.now() + n * 86_400_000).toISOString().slice(0, 10); + +/** The CRM lives outside every tenant, so each test starts from a clean slate. */ +async function wipe(): Promise { + for (const c of [ + "crmAccounts", + "crmContacts", + "crmActivities", + "crmDeals", + "crmInvoices", + "crmTickets", + ]) { + const snap = await db.collection(c).get(); + const batch = db.batch(); + snap.docs.forEach((d) => batch.delete(d.ref)); + if (snap.size) await batch.commit(); + } +} + +async function newAccount(over: Record = {}): Promise { + const res = await request("POST", "/v1/vendor/crm/accounts", { + name: "Kabul Textiles", + stage: "LEAD", + city: "Kabul", + ...over, + }); + expect(res.status).toBe(201); + return String((res.body.data as Record).id); +} + +describe.skipIf(!EMULATOR)("the vendor CRM", () => { + beforeEach(async () => { + token.claims = { ...VENDOR }; + await wipe(); + }); + + it("keeps a prospect before they are any kind of customer", async () => { + // The whole point of a pipeline: a record with no tenant behind it. + const id = await newAccount({ employeesEstimate: 45, source: "referral" }); + const row = (await request("GET", `/v1/vendor/crm/accounts/${id}`)).body.data as Record< + string, + unknown + >; + expect(row.name).toBe("Kabul Textiles"); + expect(row.stage).toBe("LEAD"); + expect(row.companyId ?? null).toBeNull(); + expect(row.createdBy).toBe("vendor_1"); + }); + + it("moves an account along the pipeline and links it to the live tenant", async () => { + const id = await newAccount(); + const res = await request("PUT", `/v1/vendor/crm/accounts/${id}`, { + name: "Kabul Textiles", + stage: "WON", + companyId: "comp_kabul", + }); + expect(res.status).toBe(200); + expect((res.body.data as Record).stage).toBe("WON"); + expect((res.body.data as Record).companyId).toBe("comp_kabul"); + }); + + it("carries contacts, activities, deals, invoices and tickets for an account", async () => { + const accountId = await newAccount(); + const made = [ + ["contacts", { accountId, name: "Ahmad", phone: "+93 700 000 000", primary: true }], + ["activities", { accountId, kind: "CALL", at: day(0), summary: "Talked about seats" }], + ["deals", { accountId, seats: 25, amountAfn: 60000, term: "YEARLY", status: "SENT" }], + ["invoices", { accountId, number: "INV-001", amountAfn: 60000, issuedAt: day(-10), dueAt: day(-3), status: "SENT" }], + ["tickets", { accountId, subject: "App will not install", openedAt: day(-1), priority: "HIGH" }], + ] as const; + + for (const [path, payload] of made) { + const res = await request("POST", `/v1/vendor/crm/${path}`, payload); + expect(res.status, path).toBe(201); + } + + for (const [path] of made) { + const list = (await request("GET", `/v1/vendor/crm/${path}?accountId=${accountId}`)).body + .data as unknown[]; + expect(list.length, path).toBe(1); + } + }); + + it("filters by account rather than returning everybody's", async () => { + const a = await newAccount({ name: "A" }); + const b = await newAccount({ name: "B" }); + await request("POST", "/v1/vendor/crm/activities", { + accountId: a, kind: "CALL", at: day(0), summary: "for A", + }); + await request("POST", "/v1/vendor/crm/activities", { + accountId: b, kind: "CALL", at: day(0), summary: "for B", + }); + + const forA = (await request("GET", `/v1/vendor/crm/activities?accountId=${a}`)).body + .data as Array>; + expect(forA).toHaveLength(1); + expect(forA[0].summary).toBe("for A"); + }); + + it("shows what is due, what is owed and what is broken, in one call", async () => { + const overdue = await newAccount({ name: "Overdue", nextActionAt: day(-2), nextAction: "Call back" }); + const soon = await newAccount({ name: "Soon", nextActionAt: day(3), nextAction: "Send quote" }); + await newAccount({ name: "Later", nextActionAt: day(30) }); + await newAccount({ name: "No action" }); + + await request("POST", "/v1/vendor/crm/invoices", { + accountId: overdue, number: "INV-1", amountAfn: 40000, issuedAt: day(-20), dueAt: day(-5), status: "SENT", + }); + await request("POST", "/v1/vendor/crm/invoices", { + accountId: soon, number: "INV-2", amountAfn: 15000, issuedAt: day(-2), status: "PAID", paidAt: day(-1), method: "HAWALA", + }); + await request("POST", "/v1/vendor/crm/deals", { + accountId: soon, seats: 10, amountAfn: 90000, status: "SENT", + }); + await request("POST", "/v1/vendor/crm/tickets", { + accountId: overdue, subject: "Cannot sign in", openedAt: day(-3), + }); + + const d = (await request("GET", "/v1/vendor/crm/dashboard")).body.data as Record; + + expect((d.dueNow as unknown[]).length).toBe(1); + expect((d.dueSoon as unknown[]).length).toBe(1); + // Only the unpaid one, and only its amount. + expect((d.unpaidInvoices as unknown[]).length).toBe(1); + expect(d.outstandingAfn).toBe(40000); + expect(d.openPipelineAfn).toBe(90000); // the sent quote, not the paid invoice + expect((d.openTickets as unknown[]).length).toBe(1); + expect((d.pipeline as Record).LEAD).toBe(4); + }); + + it("deleting an account takes its records with it", async () => { + // Otherwise an unpaid invoice would haunt the dashboard with no account to + // open and no way to reach it. + const accountId = await newAccount(); + await request("POST", "/v1/vendor/crm/invoices", { + accountId, number: "INV-9", amountAfn: 1000, issuedAt: day(-1), status: "SENT", + }); + await request("POST", "/v1/vendor/crm/tickets", { + accountId, subject: "x", openedAt: day(-1), + }); + + expect((await request("DELETE", `/v1/vendor/crm/accounts/${accountId}`)).status).toBe(204); + + const d = (await request("GET", "/v1/vendor/crm/dashboard")).body.data as Record; + expect((d.unpaidInvoices as unknown[]).length).toBe(0); + expect((d.openTickets as unknown[]).length).toBe(0); + expect(d.outstandingAfn).toBe(0); + }); + + it("validates rather than storing whatever it is sent", async () => { + const accountId = await newAccount(); + const bad = [ + ["accounts", { name: "", stage: "MAYBE" }], + ["deals", { accountId, seats: 0, amountAfn: -5 }], + ["invoices", { accountId, number: "", amountAfn: 1, issuedAt: "not-a-date" }], + ["tickets", { accountId, subject: "x", openedAt: day(0), priority: "WHENEVER" }], + ] as const; + for (const [path, payload] of bad) { + const res = await request("POST", `/v1/vendor/crm/${path}`, payload); + expect(res.status, path).toBe(422); + } + }); + + it("records every change against the person who made it", async () => { + const id = await newAccount(); + await request("PUT", `/v1/vendor/crm/accounts/${id}`, { name: "Renamed", stage: "DEMO" }); + + const log = await db.collection("vendorAuditLogs").get(); + const actions = log.docs.map((d) => d.data().action); + expect(actions).toContain("crm.accounts.create"); + expect(actions).toContain("crm.accounts.update"); + expect(log.docs.every((d) => d.data().actorEmail === "staff@linumic.com")).toBe(true); + }); + + it("is shut to a customer's token, read and write alike", async () => { + const id = await newAccount(); + token.claims = { ...CUSTOMER }; + + for (const [m, p] of [ + ["GET", "/v1/vendor/crm/accounts"], + ["GET", "/v1/vendor/crm/dashboard"], + ["GET", `/v1/vendor/crm/accounts/${id}`], + ["POST", "/v1/vendor/crm/accounts"], + ["DELETE", `/v1/vendor/crm/accounts/${id}`], + ] as const) { + const res = await request(m, p, m === "POST" ? { name: "theirs" } : undefined); + expect(res.status, `${m} ${p}`).toBe(403); + } + + token.claims = { ...VENDOR }; + expect(((await request("GET", "/v1/vendor/crm/accounts")).body.data as unknown[]).length).toBe(1); + }); + + it("404s for something that is not there rather than inventing it", async () => { + expect((await request("GET", "/v1/vendor/crm/accounts/nope")).status).toBe(404); + expect( + (await request("PUT", "/v1/vendor/crm/accounts/nope", { name: "x" })).status, + ).toBe(404); + expect((await request("DELETE", "/v1/vendor/crm/tickets/nope")).status).toBe(404); + }); +}); diff --git a/backend/functions/src/routes/devices.ts b/backend/functions/src/routes/devices.ts new file mode 100644 index 0000000..890b57c --- /dev/null +++ b/backend/functions/src/routes/devices.ts @@ -0,0 +1,130 @@ +import { Router } from "express"; +import { asyncHandler } from "../lib/errors"; +import { audit } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { parseBody } from "../middleware/validate"; +import { + activateDevice, + deviceActivateSchema, + getLicense, + listDevices, + setDeviceStatus, +} from "../services/license"; +import { getSettings } from "../services/settings"; +import { localDateOf } from "../services/attendance"; + +export const devicesRouter = Router(); + +/** + * Claims a licence seat for the calling device, or refreshes the one it holds. + * Any signed-in employee may activate the phone in their hand — the licence, + * not the role, is what limits how many devices a company can run. + */ +devicesRouter.post( + "/activate", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, deviceActivateSchema); + + // Expiry is judged against the company's own calendar date, not the + // server's — a licence must not lapse hours early in Kabul. + const settings = await getSettings(auth.companyId); + const today = localDateOf(new Date(), settings.profile.timezone); + + const result = await activateDevice(auth.companyId, auth.employeeId, payload, today); + + if (result.seatTaken) { + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "device.activate", + resourceType: "devices", + resourceId: payload.deviceId, + after: { platform: payload.platform, model: payload.model ?? null }, + }); + } + + res.status(result.seatTaken ? 201 : 200).json({ data: result }); + }), +); + +devicesRouter.get( + "/license", + requirePermission("devices:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + res.json({ data: await getLicense(auth.companyId) }); + }), +); + +/* + * There is deliberately no PUT /license. + * + * The licence is what the customer buys; it must not be something they can + * grant themselves. COMPANY_ADMIN holds "*" and HR_ADMIN holds devices:manage, + * so any endpoint here would have let a customer set their own seat count, + * clear their own expiry, or switch enforcement off. The vendor writes licences + * with backend/functions/src/scripts/set-license.ts, run against the project + * with credentials no tenant has. + */ + +devicesRouter.get( + "/", + requirePermission("devices:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const [devices, license] = await Promise.all([ + listDevices(auth.companyId), + getLicense(auth.companyId), + ]); + res.json({ + data: devices, + meta: { + deviceLimit: license.deviceLimit, + devicesInUse: devices.filter((d) => d.status === "ACTIVE").length, + }, + }); + }), +); + +/** Frees the seat: the phone stops working against this company. */ +devicesRouter.post( + "/:deviceId/revoke", + requirePermission("devices:manage"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const device = await setDeviceStatus(auth.companyId, req.params.deviceId, "REVOKED"); + + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "device.revoke", + resourceType: "devices", + resourceId: req.params.deviceId, + after: { status: "REVOKED" }, + }); + + res.json({ data: device }); + }), +); + +devicesRouter.post( + "/:deviceId/restore", + requirePermission("devices:manage"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const device = await setDeviceStatus(auth.companyId, req.params.deviceId, "ACTIVE"); + + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "device.restore", + resourceType: "devices", + resourceId: req.params.deviceId, + after: { status: "ACTIVE" }, + }); + + res.json({ data: device }); + }), +); diff --git a/backend/functions/src/routes/documents.ts b/backend/functions/src/routes/documents.ts new file mode 100644 index 0000000..dd786cb --- /dev/null +++ b/backend/functions/src/routes/documents.ts @@ -0,0 +1,113 @@ +import { Router } from "express"; +import { z } from "zod"; +import { asyncHandler } from "../lib/errors"; +import { audit } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { parseBody } from "../middleware/validate"; +import { + DOCUMENT_TYPES, + addDocument, + deleteDocument, + documentToDto, + expiringDocuments, + listDocuments, +} from "../services/employeeDocuments"; +import { getSettings } from "../services/settings"; +import { localDateOf } from "../services/attendance"; + +/** + * The register of papers a company holds for its staff. + * + * Gated on employees:read / employees:write: a document is part of somebody's + * personnel file, and whoever may see the file may see what is in it. + */ +export const documentsRouter = Router(); + +const createSchema = z.object({ + employeeId: z.string().min(1), + type: z.enum(DOCUMENT_TYPES), + number: z.string().max(80).nullish(), + issuedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullish(), + // Null for a document that does not expire — a tazkira. Not the same as + // forgetting to fill it in, which is why it is explicit. + expiresOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullish(), + note: z.string().max(300).nullish(), +}); + +documentsRouter.get( + "/", + requirePermission("employees:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const employeeId = req.query.employeeId ? String(req.query.employeeId) : null; + res.json({ data: await listDocuments(auth.companyId, employeeId) }); + }), +); + +/** What has run out, or is about to. The list somebody is meant to act on. */ +documentsRouter.get( + "/expiring", + requirePermission("employees:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const settings = await getSettings(auth.companyId); + // Dated in the company's own zone: "expires today" has to mean today where + // the company is, not where the server happens to run. + const today = localDateOf(new Date(), settings.profile.timezone); + const within = Math.min(Math.max(Number(req.query.days ?? 30) || 30, 1), 365); + res.json({ data: await expiringDocuments(auth.companyId, today, within) }); + }), +); + +documentsRouter.post( + "/", + requirePermission("employees:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, createSchema); + + const { id, doc } = await addDocument( + auth.companyId, + { + employeeId: payload.employeeId, + type: payload.type, + number: payload.number ?? null, + issuedOn: payload.issuedOn ?? null, + expiresOn: payload.expiresOn ?? null, + note: payload.note ?? null, + }, + auth.employeeId, + ); + + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "documents.create", + resourceType: "documents", + resourceId: id, + after: { employeeId: payload.employeeId, type: payload.type, expiresOn: payload.expiresOn }, + }); + + res.status(201).json({ data: documentToDto(id, doc) }); + }), +); + +documentsRouter.delete( + "/:id", + requirePermission("employees:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + await deleteDocument(auth.companyId, req.params.id); + + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "documents.delete", + resourceType: "documents", + resourceId: req.params.id, + }); + + res.json({ data: { id: req.params.id } }); + }), +); diff --git a/backend/functions/src/routes/employees.integration.test.ts b/backend/functions/src/routes/employees.integration.test.ts new file mode 100644 index 0000000..b93e86e --- /dev/null +++ b/backend/functions/src/routes/employees.integration.test.ts @@ -0,0 +1,432 @@ +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { db } from "../lib/firestore"; + +/** + * Employee codes, through the endpoint that actually assigns them. + * + * services/employees.test.ts pins the arithmetic. What it cannot see is + * whether the route reads the right collection, whether one company's numbers + * leak into another's, and whether an ordinary edit — which writes the whole + * document with set() — quietly blanks the code of somebody who already has + * one. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); + +const token = vi.hoisted(() => ({ claims: {} as Record })); + +/** + * A stand-in for Firebase Auth's user store. + * + * The Auth emulator would do, but the questions here are about what the route + * ASKS of Auth — did it disable the account, did it rewrite the claims — and a + * recording fake answers those directly instead of through a second service. + */ +const users = vi.hoisted(() => ({ + byId: new Map(), + revoked: [] as string[], +})); + +vi.mock("firebase-admin/auth", async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + getAuth: () => ({ + verifyIdToken: async () => { + if (!token.claims.uid) throw new Error("no token"); + return token.claims; + }, + getUser: async (uid: string) => { + const u = users.byId.get(uid); + if (!u) throw Object.assign(new Error("no user"), { code: "auth/user-not-found" }); + return u; + }, + getUserByEmail: async (email: string) => { + const u = [...users.byId.values()].find((x) => x.email === email); + if (!u) throw Object.assign(new Error("no user"), { code: "auth/user-not-found" }); + return u; + }, + createUser: async (u: any) => { + users.byId.set(u.uid, { ...u, disabled: false, customClaims: {} }); + return users.byId.get(u.uid); + }, + updateUser: async (uid: string, patch: any) => { + if (patch.email && [...users.byId.values()].some((x) => x.uid !== uid && x.email === patch.email)) { + throw Object.assign(new Error("taken"), { code: "auth/email-already-exists" }); + } + Object.assign(users.byId.get(uid), patch); + return users.byId.get(uid); + }, + setCustomUserClaims: async (uid: string, claims: any) => { + users.byId.get(uid).customClaims = claims; + }, + revokeRefreshTokens: async (uid: string) => { + users.revoked.push(uid); + }, + }), + }; +}); + +const { createApp } = await import("../app"); +const app = createApp(); + +async function request( + method: string, + path: string, + body?: unknown, +): Promise<{ status: number; body: Record }> { + const { createServer } = await import("node:http"); + const server = createServer(app); + await new Promise((r) => server.listen(0, r)); + const port = (server.address() as { port: number }).port; + try { + const res = await fetch(`http://127.0.0.1:${port}${path}`, { + method, + headers: { Authorization: "Bearer t", "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : {} }; + } finally { + server.close(); + } +} + +let cid = ""; +let seq = 0; + +/** No login is provisioned: this is about numbering, not about Firebase Auth. */ +function newHire(over: Record = {}): Record { + return { + firstName: "Ali", + lastName: "Rahimi", + email: `ali${seq}.${Math.random().toString(36).slice(2, 8)}@example.com`, + employmentType: "FULL_TIME", + joinDate: "2026-09-09", + createLogin: false, + ...over, + }; +} + +async function seedCompany(codes: string[]): Promise { + await db.collection("companies").doc(cid).set({ name: "Kabul Construction" }); + await Promise.all( + codes.map((employeeCode, i) => + db + .collection("companies") + .doc(cid) + .collection("employees") + .doc(`e_seed_${i}`) + .set({ employeeCode, firstName: "Seed", lastName: `${i}`, status: "ACTIVE" }), + ), + ); +} + +describe.skipIf(!EMULATOR)("employee codes", () => { + beforeEach(() => { + seq += 1; + cid = `c_emp_${seq}`; + users.byId.clear(); + users.revoked = []; + token.claims = { uid: "u_admin", cid, eid: "e_admin", r: ["HR_ADMIN"], email_verified: true }; + }); + + // The emulator is shared with every other suite in the run, and the vendor + // console asserts over EVERY company it can see — so a tenant left behind + // here fails a test three files away, with a message that points nowhere + // near the cause. Take out what this file put in. + afterEach(async () => { + await Promise.all( + [cid, `${cid}_other`].map((id) => + db.recursiveDelete(db.collection("companies").doc(id)), + ), + ); + }); + + it("continues from the code signup wrote for the founding admin", async () => { + await seedCompany(["E-001"]); + + const res = await request("POST", "/v1/employees", newHire()); + + expect(res.status).toBe(201); + expect(res.body.data.employeeCode).toBe("E-002"); + }); + + it("keeps numbering across several hires", async () => { + await seedCompany(["E-001"]); + + const codes: string[] = []; + for (let i = 0; i < 3; i += 1) { + const res = await request("POST", "/v1/employees", newHire()); + expect(res.status).toBe(201); + codes.push(res.body.data.employeeCode); + } + + expect(codes).toEqual(["E-002", "E-003", "E-004"]); + }); + + it("counts from the highest code, not from how many people there are", async () => { + // Somebody left and was removed. Numbering by headcount would hand out a + // code another employee already holds. + await seedCompany(["E-001", "E-004", "E-009"]); + + const res = await request("POST", "/v1/employees", newHire()); + + expect(res.body.data.employeeCode).toBe("E-010"); + }); + + it("leaves a company's own numbering alone", async () => { + // Staff imported from an older payroll system. Those codes are theirs. + await seedCompany(["1042", "1043"]); + + const res = await request("POST", "/v1/employees", newHire()); + + expect(res.body.data.employeeCode).toBe("E-001"); + }); + + it("still honours a code typed by hand", async () => { + await seedCompany(["E-001"]); + + const res = await request("POST", "/v1/employees", newHire({ employeeCode: "ACC-77" })); + + expect(res.body.data.employeeCode).toBe("ACC-77"); + }); + + it("does not read another company's numbers", async () => { + // The generator queries a tenant subcollection; if it ever reached across + // tenants, a busy neighbour would push this company's first hire to E-500. + const other = `${cid}_other`; + await db.collection("companies").doc(other).set({ name: "Someone else" }); + await db + .collection("companies") + .doc(other) + .collection("employees") + .doc("e_x") + .set({ employeeCode: "E-500", firstName: "X", lastName: "Y", status: "ACTIVE" }); + await seedCompany(["E-001"]); + + const res = await request("POST", "/v1/employees", newHire()); + + expect(res.body.data.employeeCode).toBe("E-002"); + }); + + it("does not blank an existing code when an edit omits it", async () => { + // The update route writes the whole document with set(). The form no + // longer sends a code, so without carrying the old one across, editing + // somebody's phone number would erase the number payroll knows them by. + await seedCompany(["E-001"]); + const created = await request("POST", "/v1/employees", newHire()); + const id = created.body.data.id as string; + expect(created.body.data.employeeCode).toBe("E-002"); + + const edited = await request("PUT", `/v1/employees/${id}`, { + firstName: "Ali", + lastName: "Rahimi", + email: created.body.data.email, + phone: "0700000000", + employmentType: "FULL_TIME", + joinDate: "2026-09-09", + }); + + expect(edited.status).toBe(200); + expect(edited.body.data.employeeCode).toBe("E-002"); + }); + + it("lets an edit change the code on purpose", async () => { + await seedCompany(["E-001"]); + const created = await request("POST", "/v1/employees", newHire()); + const id = created.body.data.id as string; + + const edited = await request("PUT", `/v1/employees/${id}`, { + employeeCode: "E-050", + firstName: "Ali", + lastName: "Rahimi", + email: created.body.data.email, + employmentType: "FULL_TIME", + joinDate: "2026-09-09", + }); + + expect(edited.body.data.employeeCode).toBe("E-050"); + }); +}); + +/** + * Editing an employee, and the account that edit is supposed to reach. + * + * Before this, PUT wrote Firestore and stopped. Everything below is a way the + * record and the login could disagree while the screen said it had worked. + */ +describe.skipIf(!EMULATOR)("editing reaches the login", () => { + let id = ""; + + async function hire(over: Record = {}): Promise { + const res = await request("POST", "/v1/employees", { + ...newHire({ createLogin: true, initialPassword: "Passw0rd!", ...over }), + }); + expect(res.status).toBe(201); + return res.body.data.id as string; + } + + function edit(over: Record = {}): Record { + return { + firstName: "Ali", + lastName: "Rahimi", + email: users.byId.get(id).email, + employmentType: "FULL_TIME", + joinDate: "2026-09-09", + ...over, + }; + } + + beforeEach(async () => { + await seedCompany(["E-001"]); + id = await hire(); + }); + + it("moves the login when the email moves", async () => { + // The bug this replaces: the record showed the new address and the person + // went on signing in with the old one, with nothing saying so. + const res = await request("PUT", `/v1/employees/${id}`, edit({ email: "moved@example.com" })); + + expect(res.status).toBe(200); + expect(users.byId.get(id).email).toBe("moved@example.com"); + }); + + it("refuses an email another account already holds", async () => { + const other = await hire({ email: "taken@example.com" }); + expect(other).not.toBe(id); + + const res = await request("PUT", `/v1/employees/${id}`, edit({ email: "taken@example.com" })); + + expect(res.status).toBe(409); + // And the record did not move either — Auth is updated first for exactly + // this reason. + expect(users.byId.get(id).email).not.toBe("taken@example.com"); + }); + + it("shuts off access when somebody leaves", async () => { + // The one that matters most: before this, EXITED changed a chip in a table + // and the person kept every permission they had the day before. + expect(users.byId.get(id).disabled).toBe(false); + + await request("PUT", `/v1/employees/${id}`, edit({ status: "EXITED" })); + + expect(users.byId.get(id).disabled).toBe(true); + expect(users.revoked).toContain(id); + }); + + it("leaves somebody on leave able to sign in", async () => { + // Still employed: they need their payslip and the leave that follows. + await request("PUT", `/v1/employees/${id}`, edit({ status: "ON_LEAVE" })); + + expect(users.byId.get(id).disabled).toBe(false); + }); + + it("lets somebody come back", async () => { + await request("PUT", `/v1/employees/${id}`, edit({ status: "SUSPENDED" })); + expect(users.byId.get(id).disabled).toBe(true); + + await request("PUT", `/v1/employees/${id}`, edit({ status: "ACTIVE" })); + expect(users.byId.get(id).disabled).toBe(false); + }); + + it("changes a role, in the claims and not only on paper", async () => { + // The claims ARE the authorisation — the middleware reads them and never + // opens the employee document. + expect(users.byId.get(id).customClaims.r).toEqual(["EMPLOYEE"]); + + const res = await request("PUT", `/v1/employees/${id}`, edit({ role: "TEAM_LEAD" })); + + expect(res.status).toBe(200); + expect(users.byId.get(id).customClaims.r).toEqual(["TEAM_LEAD"]); + expect(users.revoked).toContain(id); + }); + + it("shows the new role back on the record, not just in the claims", async () => { + // The portal cannot read a claim per row, so a role it cannot see is a + // role nobody can correct. + const res = await request("PUT", `/v1/employees/${id}`, edit({ role: "TEAM_LEAD" })); + expect(res.body.data.role).toBe("TEAM_LEAD"); + + const stored = await request("GET", `/v1/employees/${id}`); + expect(stored.body.data.role).toBe("TEAM_LEAD"); + }); + + it("moves the branch in the claims too", async () => { + await request("PUT", `/v1/employees/${id}`, edit({ branchId: "b_herat" })); + + expect(users.byId.get(id).customClaims.b).toEqual(["b_herat"]); + }); + + it("refuses to mint a company administrator", async () => { + const res = await request("PUT", `/v1/employees/${id}`, edit({ role: "COMPANY_ADMIN" })); + + // Two layers refuse this and the schema gets there first (422), which is + // why the assertion is on the outcome rather than on which one spoke. The + // guard in roleChangeRefusal still matters: it is what holds if the schema + // ever gains a role the rules should not allow to be handed out. + expect([403, 422]).toContain(res.status); + expect(users.byId.get(id).customClaims.r).toEqual(["EMPLOYEE"]); + }); + + it("refuses an HR admin demoting the owner", async () => { + // Every role in this request is assignable, so checking only the new role + // would let it through — and afterwards the company has no owner. + users.byId.get(id).customClaims = { cid, eid: id, r: ["COMPANY_ADMIN"], b: [] }; + + const res = await request("PUT", `/v1/employees/${id}`, edit({ role: "EMPLOYEE" })); + + expect(res.status).toBe(403); + expect(users.byId.get(id).customClaims.r).toEqual(["COMPANY_ADMIN"]); + }); + + it("refuses anybody changing their own role", async () => { + token.claims = { uid: id, cid, eid: id, r: ["HR_ADMIN"], email_verified: true }; + + const res = await request("PUT", `/v1/employees/${id}`, edit({ role: "AUDITOR" })); + + expect(res.status).toBe(403); + }); + + it("leaves the role alone when the edit does not mention it", async () => { + // The form sends a whole document. Ordinary edits must not silently reset + // somebody to EMPLOYEE. + await request("PUT", `/v1/employees/${id}`, edit({ role: "TEAM_LEAD" })); + expect(users.byId.get(id).customClaims.r).toEqual(["TEAM_LEAD"]); + + await request("PUT", `/v1/employees/${id}`, edit({ phone: "0700000000" })); + + expect(users.byId.get(id).customClaims.r).toEqual(["TEAM_LEAD"]); + }); + + it("says in the audit trail what happened to the account", async () => { + await request("PUT", `/v1/employees/${id}`, edit({ status: "EXITED" })); + + const log = await db.collection(`companies/${cid}/auditLogs`).get(); + const actions = log.docs.map((d) => d.data().action as string); + expect(actions.some((a) => a.includes("access revoked"))).toBe(true); + }); + + it("does not fall over for somebody who never had a login", async () => { + // A company may hold records for people who never touch the app. + const recordOnly = await request("POST", "/v1/employees", newHire({ createLogin: false })); + const rid = recordOnly.body.data.id as string; + + const res = await request("PUT", `/v1/employees/${rid}`, { + firstName: "No", + lastName: "Login", + email: recordOnly.body.data.email, + employmentType: "FULL_TIME", + joinDate: "2026-09-09", + status: "EXITED", + }); + + expect(res.status).toBe(200); + }); + + afterEach(async () => { + await db.recursiveDelete(db.collection("companies").doc(cid)); + }); +}); diff --git a/backend/functions/src/routes/employees.ts b/backend/functions/src/routes/employees.ts new file mode 100644 index 0000000..eb2de93 --- /dev/null +++ b/backend/functions/src/routes/employees.ts @@ -0,0 +1,434 @@ +import { Router } from "express"; +import { Timestamp } from "firebase-admin/firestore"; +import type { Query } from "firebase-admin/firestore"; +import { z } from "zod"; +import { ApiError, ErrorCodes, asyncHandler } from "../lib/errors"; +import { audit, db, nowTimestamp, tenant, toIso } from "../lib/firestore"; +import { ulid } from "../lib/ids"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { parseBody } from "../middleware/validate"; +import { getAuth } from "firebase-admin/auth"; +import { roleChangeRefusal, type EmploymentStatus } from "../services/employeeAccount"; +import { syncEmployeeLogin } from "../services/employeeSync"; +import { nextEmployeeCode } from "../services/employees"; +import { clearFace } from "../services/face"; +import { + ASSIGNABLE_ROLES, + createEmployeeLogin, + resetEmployeePassword, +} from "../services/invite"; + +export const employeesRouter = Router(); + +interface EmployeeDoc { + employeeCode: string; + firstName: string; + lastName: string; + email: string; + phone?: string | null; + avatarUrl?: string | null; + branchId?: string | null; + departmentId?: string | null; + positionId?: string | null; + managerId?: string | null; + employmentType: string; + joinDate: string; + status: string; + /** + * A COPY of the role, for display and filtering only. + * + * The custom claim on the login is what the server enforces; nothing reads + * this to decide anything. It exists because the portal could not show a + * role at all otherwise — claims are not readable per row in a list — and a + * role nobody can see is a role nobody can correct. Both are written in the + * same handler, so they move together. Absent on employees created before + * this shipped, which is why the portal treats absent as "unknown" rather + * than as EMPLOYEE. + */ + role?: string | null; + faceEmbedding?: unknown; + updatedAt: Timestamp; +} + +function employeeToDto(id: string, companyId: string, doc: EmployeeDoc): Record { + return { + id, + companyId, + employeeCode: doc.employeeCode, + firstName: doc.firstName, + lastName: doc.lastName, + email: doc.email, + phone: doc.phone ?? null, + avatarUrl: doc.avatarUrl ?? null, + branchId: doc.branchId ?? null, + departmentId: doc.departmentId ?? null, + positionId: doc.positionId ?? null, + managerId: doc.managerId ?? null, + role: doc.role ?? null, + employmentType: doc.employmentType, + joinDate: doc.joinDate, + status: doc.status, + faceEnrolled: Array.isArray(doc.faceEmbedding), + updatedAt: toIso(doc.updatedAt), + }; +} + +/** + * Employee directory. Branch-scoped managers see only their branches; company/ + * HR admins see everyone. Cursor pagination on the document id (employeeCode + * order would need a composite index; id order is stable and index-free). + */ +employeesRouter.get( + "/", + requirePermission("employees:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const limit = Math.min(Number.parseInt(String(req.query.limit ?? "50"), 10) || 50, 100); + const cursor = req.query.cursor ? String(req.query.cursor) : null; + const branchFilter = req.query.branchId ? String(req.query.branchId) : null; + const statusFilter = req.query.status ? String(req.query.status) : null; + + let query: Query = tenant(auth.companyId, "employees"); + + // A branch manager is confined to the branches on their token claim. + const companyWide = + auth.roles.includes("COMPANY_ADMIN") || + auth.roles.includes("HR_ADMIN") || + auth.roles.includes("AUDITOR") || + auth.roles.includes("PAYROLL_ADMIN") || + auth.roles.includes("SUPER_ADMIN"); + + const effectiveBranch = branchFilter ?? (!companyWide ? auth.branchIds[0] ?? null : null); + if (effectiveBranch) { + query = query.where("branchId", "==", effectiveBranch); + } + if (statusFilter) { + query = query.where("status", "==", statusFilter); + } + + query = query.orderBy("__name__").limit(limit); + if (cursor) { + query = query.startAfter(cursor); + } + + const snapshot = await query.get(); + const data = snapshot.docs.map((doc) => + employeeToDto(doc.id, auth.companyId, doc.data() as EmployeeDoc), + ); + const last = snapshot.docs[snapshot.docs.length - 1]; + + res.json({ + data, + meta: { + cursor: snapshot.size === limit && last ? last.id : null, + hasMore: snapshot.size === limit, + }, + }); + }), +); + +employeesRouter.get( + "/:id", + requirePermission("employees:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const doc = await tenant(auth.companyId, "employees").doc(req.params.id).get(); + if (!doc.exists) { + throw ApiError.notFound("Employee not found"); + } + res.json({ data: employeeToDto(doc.id, auth.companyId, doc.data() as EmployeeDoc) }); + }), +); + +const employeeWriteSchema = z.object({ + // Optional, and generated when it is left out — see services/employees.ts. + // On an edit, leaving it out keeps the code the employee already has; a + // full set() would otherwise blank it, and this schema serves both routes. + employeeCode: z.string().min(1).max(40).optional(), + firstName: z.string().min(1).max(100), + lastName: z.string().min(1).max(100), + email: z.string().email(), + phone: z.string().max(40).nullish(), + branchId: z.string().nullish(), + departmentId: z.string().nullish(), + positionId: z.string().nullish(), + managerId: z.string().nullish(), + employmentType: z.enum(["FULL_TIME", "PART_TIME", "CONTRACT", "INTERN"]), + joinDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + status: z.enum(["ACTIVE", "ON_LEAVE", "SUSPENDED", "EXITED"]).default("ACTIVE"), + // Login provisioning (create only): give the new employee a mobile-app login. + // No default. A default here is indistinguishable from a deliberate choice + // once it reaches the handler, and the update path reads it as one: an edit + // to somebody's phone number would have demoted a team lead to EMPLOYEE + // without anybody asking for it. Create supplies the default itself. + role: z.enum(ASSIGNABLE_ROLES).optional(), + createLogin: z.boolean().default(true), + initialPassword: z.string().min(8).max(100).optional(), +}); + +function toDoc( + payload: z.infer, + avatarUrl: string | null, + employeeCode: string, + role: string | null, +): EmployeeDoc { + return { + employeeCode, + role, + firstName: payload.firstName, + lastName: payload.lastName, + email: payload.email, + phone: payload.phone ?? null, + branchId: payload.branchId ?? null, + departmentId: payload.departmentId ?? null, + positionId: payload.positionId ?? null, + managerId: payload.managerId ?? null, + employmentType: payload.employmentType, + joinDate: payload.joinDate, + status: payload.status, + avatarUrl, + updatedAt: nowTimestamp(), + }; +} + +employeesRouter.post( + "/", + requirePermission("employees:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, employeeWriteSchema); + const id = ulid(); + const employees = tenant(auth.companyId, "employees"); + + // Create the login FIRST so a duplicate-email failure doesn't leave an + // orphaned employee record behind. + let tempPassword: string | null = null; + if (payload.createLogin) { + tempPassword = await createEmployeeLogin({ + companyId: auth.companyId, + employeeId: id, + email: payload.email, + displayName: `${payload.firstName} ${payload.lastName}`.trim(), + role: payload.role ?? "EMPLOYEE", + branchIds: payload.branchId ? [payload.branchId] : [], + password: payload.initialPassword, + }); + } + + // Reading the codes and writing the new one in one transaction. Two + // administrators adding somebody in the same moment would otherwise both + // read the same highest code and both be handed it: nothing enforces + // uniqueness on a display code, so the collision would be silent and + // permanent, and payroll would have two people answering to E-014. + const doc = await db.runTransaction(async (tx) => { + let code = payload.employeeCode?.trim(); + if (!code) { + const snap = await tx.get(employees.select("employeeCode")); + code = nextEmployeeCode(snap.docs.map((d) => d.get("employeeCode") as string)); + } + const created = toDoc(payload, null, code, payload.role ?? "EMPLOYEE"); + tx.create(employees.doc(id), created); + return created; + }); + await seedLeaveBalances(auth.companyId, id); + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "employees.create", + resourceType: "employees", + resourceId: id, + after: { employeeCode: doc.employeeCode, email: payload.email, role: payload.role }, + }); + res.status(201).json({ + data: { ...employeeToDto(id, auth.companyId, doc), tempPassword }, + }); + }), +); + +const resetPasswordSchema = z.object({ + // Optional: a manager-chosen permanent password; omitted → random temp one. + password: z.string().min(8).max(100).optional(), +}); + +/** Set an employee's login password — a manager-chosen one, or a random temp. */ +employeesRouter.post( + "/:id/reset-password", + requirePermission("employees:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const { password } = parseBody(req, resetPasswordSchema); + const emp = await tenant(auth.companyId, "employees").doc(req.params.id).get(); + if (!emp.exists) { + throw ApiError.notFound("Employee not found"); + } + const tempPassword = await resetEmployeePassword(req.params.id, password); + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "employees.reset_password", + resourceType: "employees", + resourceId: req.params.id, + }); + res.json({ data: { tempPassword } }); + }), +); + +employeesRouter.put( + "/:id", + requirePermission("employees:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, employeeWriteSchema); + const ref = tenant(auth.companyId, "employees").doc(req.params.id); + const existing = await ref.get(); + if (!existing.exists) { + throw ApiError.notFound("Employee not found"); + } + const existingDoc = existing.data() as EmployeeDoc & { faceEnrolledAt?: unknown }; + // Omitting the code on an edit means "leave it alone", not "clear it". + const doc = toDoc( + payload, + existingDoc.avatarUrl ?? null, + payload.employeeCode?.trim() || existingDoc.employeeCode, + // Filled in below once the role change has been allowed; an edit that + // does not mention a role must not disturb the one on record. + existingDoc.role ?? null, + ); + + // The login this record belongs to. Everything below decides what may + // change about it; syncEmployeeLogin then makes it so, because a record + // that disagrees with its own account is worse than one that cannot be + // edited at all — it looks like it worked. + const account = await getAuth().getUser(req.params.id).catch(() => null); + const currentRoles = (account?.customClaims?.r as string[] | undefined) ?? []; + // Omitting the role means "leave it alone", the same as the code. + const nextRole = payload.role && payload.role !== currentRoles[0] ? payload.role : null; + + if (nextRole) { + const refusal = roleChangeRefusal({ + actorEmployeeId: auth.employeeId, + actorRoles: auth.roles, + targetEmployeeId: req.params.id, + targetCurrentRoles: currentRoles, + newRole: nextRole, + }); + if (refusal) { + throw new ApiError(403, ErrorCodes.PERMISSION_DENIED, refusal); + } + } + // A full set() would otherwise wipe face enrollment; carry it across edits. + const preserved: Record = { ...doc }; + if (existingDoc.faceEmbedding !== undefined) preserved.faceEmbedding = existingDoc.faceEmbedding; + if (existingDoc.faceEnrolledAt !== undefined) preserved.faceEnrolledAt = existingDoc.faceEnrolledAt; + // Auth first, Firestore second — the same order the create path uses, and + // for the same reason: an email already taken by somebody else must fail + // before the record moves, not after. + const sync = await syncEmployeeLogin({ + companyId: auth.companyId, + employeeId: req.params.id, + email: doc.email, + displayName: `${doc.firstName} ${doc.lastName}`.trim(), + role: nextRole ?? currentRoles[0] ?? "EMPLOYEE", + branchId: doc.branchId ?? null, + status: doc.status as EmploymentStatus, + }); + // After the sync, not before: until Auth has accepted the change there is + // nothing to record. `preserved` was spread from `doc` further up, so both + // have to be told. + if (nextRole) { + doc.role = nextRole; + preserved.role = nextRole; + } + + await ref.set(preserved); + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + // Role and access changes are the ones somebody will need to account for + // later, so they are named rather than buried in a document diff. + action: sync.changed.length + ? `employees.update (${sync.changed.join(", ")})` + : "employees.update", + resourceType: "employees", + resourceId: req.params.id, + before: employeeToDto(req.params.id, auth.companyId, existing.data() as EmployeeDoc), + after: employeeToDto(req.params.id, auth.companyId, doc), + }); + res.json({ data: employeeToDto(req.params.id, auth.companyId, doc) }); + }), +); + +/** Admin: clear an employee's face enrollment (e.g. re-enroll after a bad capture). */ +employeesRouter.delete( + "/:id/face", + requirePermission("employees:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const ref = tenant(auth.companyId, "employees").doc(req.params.id); + if (!(await ref.get()).exists) { + throw ApiError.notFound("Employee not found"); + } + await clearFace(auth.companyId, req.params.id); + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "employees.face.reset", + resourceType: "employees", + resourceId: req.params.id, + }); + res.json({ data: { faceEnrolled: false } }); + }), +); + +/** + * Gives a new employee an entitlement row for every active leave type. + * + * Only the signup flow ever created these, so everyone added through the portal + * had no balance at all. Leave now refuses a request with no entitlement, which + * would strand them — so the rows are created up front, at the leave type's + * default. A zero-entitlement type still gets a row, so the refusal that + * follows is a deliberate "none granted" rather than "nothing configured". + */ +async function seedLeaveBalances(cid: string, employeeId: string): Promise { + const typesSnap = await tenant(cid, "leaveTypes").get(); + if (typesSnap.empty) return; + + const periodYear = new Date().getUTCFullYear(); + const now = nowTimestamp(); + const batch = db.batch(); + for (const typeDoc of typesSnap.docs) { + const type = typeDoc.data() as { defaultEntitlementDays?: number; active?: boolean }; + if (type.active === false) continue; + + // Tenants created before the type carried a default still have balances on + // their existing staff — mirror one rather than granting nobody anything. + let entitledDays = type.defaultEntitlementDays; + if (entitledDays === undefined) { + const peer = await tenant(cid, "leaveBalances") + .where("leaveTypeId", "==", typeDoc.id) + .limit(1) + .get(); + entitledDays = peer.empty + ? 0 + : ((peer.docs[0].data().entitledDays as number | undefined) ?? 0); + } + + batch.set( + tenant(cid, "leaveBalances").doc(`${employeeId}_${typeDoc.id}_${periodYear}`), + { + employeeId, + leaveTypeId: typeDoc.id, + periodYear, + entitledDays, + accruedDays: 0, + usedDays: 0, + carriedOverDays: 0, + pendingDays: 0, + updatedAt: now, + }, + { merge: true }, + ); + } + await batch.commit(); +} diff --git a/backend/functions/src/routes/finance.ts b/backend/functions/src/routes/finance.ts new file mode 100644 index 0000000..51b2418 --- /dev/null +++ b/backend/functions/src/routes/finance.ts @@ -0,0 +1,200 @@ +import { Router } from "express"; +import { z } from "zod"; +import { asyncHandler } from "../lib/errors"; +import { audit, db } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { parseBody } from "../middleware/validate"; +import { + ACCOUNT_TYPES, + computeTrialBalance, + createAccount, + listAccounts, + listJournalEntries, + postJournalEntry, +} from "../services/accounting"; +import { + createExpense, + decideExpense, + listExpenses, +} from "../services/expenses"; +import { financeOverview } from "../services/finance-reports"; + +export const financeRouter = Router(); + +async function currencyOf(cid: string): Promise { + const snap = await db.collection("companies").doc(cid).get(); + const data = snap.data() as { currency?: string; settings?: { profile?: { currency?: string } } } | undefined; + return data?.settings?.profile?.currency ?? data?.currency ?? "AFN"; +} + +// ------------------------------------------------------------------- overview + +financeRouter.get( + "/overview", + requirePermission("finance:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const currency = await currencyOf(auth.companyId); + res.json({ data: await financeOverview(auth.companyId, currency) }); + }), +); + +financeRouter.get( + "/trial-balance", + requirePermission("finance:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + res.json({ data: await computeTrialBalance(auth.companyId) }); + }), +); + +// ------------------------------------------------------------------- expenses + +financeRouter.get( + "/expenses", + requirePermission("expenses:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const status = typeof req.query.status === "string" ? req.query.status : undefined; + res.json({ data: await listExpenses(auth.companyId, status) }); + }), +); + +const expenseCreateSchema = z.object({ + category: z.enum(["rent", "utilities", "supplies", "travel", "services", "other"]), + vendor: z.string().min(1).max(160), + description: z.string().max(500).default(""), + amount: z.number().positive().max(1_000_000_000), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD"), +}); + +financeRouter.post( + "/expenses", + requirePermission("expenses:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const body = parseBody(req, expenseCreateSchema); + const currency = await currencyOf(auth.companyId); + const expense = await createExpense(auth.companyId, { ...body, currency }, auth.employeeId); + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "expenses.create", + resourceType: "expenses", + resourceId: expense.id, + after: { vendor: expense.vendor, amount: expense.amount }, + }); + res.status(201).json({ data: expense }); + }), +); + +const expenseDecideSchema = z.object({ + action: z.enum(["APPROVE", "REJECT", "PAY"]), +}); + +financeRouter.post( + "/expenses/:id/decide", + requirePermission("expenses:approve"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const { action } = parseBody(req, expenseDecideSchema); + const expense = await decideExpense(auth.companyId, req.params.id, action, auth.employeeId); + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: `expenses.${action.toLowerCase()}`, + resourceType: "expenses", + resourceId: expense.id, + after: { status: expense.status }, + }); + res.json({ data: expense }); + }), +); + +// --------------------------------------------------------------------- ledger + +financeRouter.get( + "/accounts", + requirePermission("ledger:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + res.json({ data: await listAccounts(auth.companyId) }); + }), +); + +const accountCreateSchema = z.object({ + code: z.string().regex(/^\d{3,6}$/, "Account code is 3–6 digits"), + name: z.string().min(1).max(120), + type: z.enum(ACCOUNT_TYPES as [string, ...string[]]), +}); + +financeRouter.post( + "/accounts", + requirePermission("ledger:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const body = parseBody(req, accountCreateSchema); + const account = await createAccount(auth.companyId, { + code: body.code, + name: body.name, + type: body.type as (typeof ACCOUNT_TYPES)[number], + }); + res.status(201).json({ data: account }); + }), +); + +financeRouter.get( + "/journal", + requirePermission("ledger:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + res.json({ data: await listJournalEntries(auth.companyId) }); + }), +); + +const journalLineSchema = z.object({ + accountCode: z.string().min(1), + accountName: z.string().default(""), + debit: z.number().min(0).default(0), + credit: z.number().min(0).default(0), +}); + +const journalCreateSchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD"), + memo: z.string().min(1).max(300), + lines: z.array(journalLineSchema).min(2), +}); + +financeRouter.post( + "/journal", + requirePermission("ledger:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const body = parseBody(req, journalCreateSchema); + // Fill account names from the chart when the client omits them. + const accounts = await listAccounts(auth.companyId); + const nameByCode = new Map(accounts.map((a) => [a.code, a.name])); + const lines = body.lines.map((l) => ({ + accountCode: l.accountCode, + accountName: l.accountName || nameByCode.get(l.accountCode) || l.accountCode, + debit: l.debit, + credit: l.credit, + })); + const id = await postJournalEntry(auth.companyId, { + date: body.date, + memo: body.memo, + source: "MANUAL", + createdBy: auth.employeeId, + lines, + }); + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "ledger.journal.create", + resourceType: "journalEntries", + resourceId: id, + }); + res.status(201).json({ data: { id } }); + }), +); diff --git a/backend/functions/src/routes/kiosk.ts b/backend/functions/src/routes/kiosk.ts new file mode 100644 index 0000000..7654124 --- /dev/null +++ b/backend/functions/src/routes/kiosk.ts @@ -0,0 +1,68 @@ +import { Router } from "express"; +import { asyncHandler } from "../lib/errors"; +import { db } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { parseBody } from "../middleware/validate"; +import { signKioskToken } from "../services/kiosk"; +import { + createKioskAccount, + kioskAccountCreateSchema, + listKioskAccounts, + resetKioskPassword, +} from "../services/kiosk-account"; +import { kioskSecret } from "../config"; + +export const kioskRouter = Router(); + +/** + * Issues the current rotating kiosk token for a shared check-in screen. The + * kiosk page polls this every ~20s and renders the token as a QR; employees + * scan it in the app to punch. The HMAC secret never leaves the server, so the + * token must be minted here. Requires kiosk:issue (managers/admins or a + * dedicated KIOSK device account). + */ +kioskRouter.get( + "/token", + requirePermission("kiosk:issue"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const kioskId = String(req.query.kioskId ?? auth.branchIds[0] ?? "kiosk").slice(0, 64); + const token = signKioskToken(kioskSecret.value(), kioskId); + const companySnap = await db.collection("companies").doc(auth.companyId).get(); + const companyName = (companySnap.data()?.name as string | undefined) ?? ""; + res.json({ data: { token, kioskId, companyName, rotateSeconds: 30 } }); + }), +); + +// ------------------------------------------------------ dedicated accounts + +/** Provision a KIOSK-role login for an unattended device. */ +kioskRouter.post( + "/accounts", + requirePermission("employees:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, kioskAccountCreateSchema); + const dto = await createKioskAccount(auth.companyId, payload, auth.employeeId, auth.roles); + res.status(201).json({ data: dto }); + }), +); + +kioskRouter.get( + "/accounts", + requirePermission("employees:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + res.json({ data: await listKioskAccounts(auth.companyId) }); + }), +); + +kioskRouter.post( + "/accounts/:id/reset", + requirePermission("employees:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + res.json({ data: await resetKioskPassword(auth.companyId, req.params.id) }); + }), +); diff --git a/backend/functions/src/routes/leave.ts b/backend/functions/src/routes/leave.ts new file mode 100644 index 0000000..64e8a92 --- /dev/null +++ b/backend/functions/src/routes/leave.ts @@ -0,0 +1,116 @@ +import { Router } from "express"; +import { Timestamp } from "firebase-admin/firestore"; +import { ApiError, asyncHandler } from "../lib/errors"; +import { tenant, toIso } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { isApprover } from "../middleware/rbac"; +import { withIdempotency } from "../middleware/idempotency"; +import { parseBody } from "../middleware/validate"; +import { + cancelLeaveRequest, + createLeaveRequest, + decideLeaveRequest, + leaveCreateSchema, + leaveDecisionSchema, + listLeaveRequests, +} from "../services/leave"; + +export const leaveRouter = Router(); + +leaveRouter.get( + "/types", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const snapshot = await tenant(auth.companyId, "leaveTypes") + .where("active", "==", true) + .get(); + res.json({ + data: snapshot.docs.map((doc) => ({ + id: doc.id, + companyId: auth.companyId, + ...doc.data(), + updatedAt: toIso(doc.data().updatedAt as Timestamp | null), + })), + }); + }), +); + +leaveRouter.get( + "/balances", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const snapshot = await tenant(auth.companyId, "leaveBalances") + .where("employeeId", "==", auth.employeeId) + .get(); + res.json({ + data: snapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + updatedAt: toIso(doc.data().updatedAt as Timestamp | null), + })), + }); + }), +); + +/** scope=mine (default) or scope=approvals (requests waiting on the caller). */ +leaveRouter.get( + "/requests", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const scope = String(req.query.scope ?? "mine"); + + if (scope === "approvals" && !isApprover(auth.roles)) { + throw ApiError.permissionDenied("Requires leave:approve"); + } + + res.json({ + data: await listLeaveRequests(auth.companyId, auth.employeeId, auth.roles, scope), + }); + }), +); + +leaveRouter.post( + "/requests", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, leaveCreateSchema); + const dto = await createLeaveRequest(auth.companyId, auth.employeeId, payload); + res.status(201).json({ data: dto }); + }), +); + +leaveRouter.post( + "/requests/:id/decide", + asyncHandler(async (req, res) => { + const auth = authOf(req); + if (!isApprover(auth.roles)) { + throw ApiError.permissionDenied("Requires leave:approve"); + } + const payload = parseBody(req, leaveDecisionSchema); + + const { result: dto } = await withIdempotency( + auth.companyId, + req.header("Idempotency-Key"), + () => + decideLeaveRequest( + auth.companyId, + req.params.id, + auth.employeeId, + auth.roles, + payload.decision, + payload.note ?? null, + ), + ); + + res.json({ data: dto }); + }), +); + +leaveRouter.post( + "/requests/:id/cancel", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const dto = await cancelLeaveRequest(auth.companyId, req.params.id, auth.employeeId); + res.json({ data: dto }); + }), +); diff --git a/backend/functions/src/routes/me.ts b/backend/functions/src/routes/me.ts new file mode 100644 index 0000000..ec0311f --- /dev/null +++ b/backend/functions/src/routes/me.ts @@ -0,0 +1,80 @@ +import { Router } from "express"; +import { ApiError, asyncHandler } from "../lib/errors"; +import { tenant, db } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { parseBody } from "../middleware/validate"; +import { mergeSettings } from "../services/settings"; +import { embeddingSchema, enrollFace } from "../services/face"; + +export const meRouter = Router(); + +/** Resolves the caller's profile + tenant context for session bootstrap. */ +meRouter.get( + "/", + asyncHandler(async (req, res) => { + const auth = authOf(req); + + const [companySnap, employeeSnap] = await Promise.all([ + db.collection("companies").doc(auth.companyId).get(), + tenant(auth.companyId, "employees").doc(auth.employeeId).get(), + ]); + if (!companySnap.exists || !employeeSnap.exists) { + throw ApiError.permissionDenied("Account is not provisioned for any company"); + } + const company = companySnap.data() as + | { name?: string; currency?: string; settings?: Parameters[0] } + | undefined; + const employee = employeeSnap.data() as { + firstName?: string; + lastName?: string; + email?: string; + avatarUrl?: string | null; + faceEmbedding?: unknown; + }; + + // Feature flags let both apps hide modules the company has turned off. + const settings = mergeSettings(company?.settings); + + res.json({ + data: { + uid: auth.uid, + companyId: auth.companyId, + companyName: company?.name ?? "", + currency: settings.profile.currency, + // Attendance days are filed in the company's timezone, so every client + // must resolve "today" in it — a manager abroad would otherwise ask for + // their own local date and see an empty board. + timezone: settings.profile.timezone, + employeeId: auth.employeeId, + displayName: + [employee.firstName, employee.lastName].filter(Boolean).join(" ") || "Employee", + email: employee.email ?? "", + avatarUrl: employee.avatarUrl ?? null, + roles: auth.roles, + branchIds: auth.branchIds, + features: settings.features, + faceEnrolled: Array.isArray(employee.faceEmbedding), + }, + }); + }), +); + +/** + * Enroll the caller's own face: the app computes the embedding on-device and + * sends only the numeric vector (never a photo). Stored on the employee record + * and used to verify future face check-ins. + * + * Gated on self:punch so shared device accounts (kiosks) cannot enroll a face. + * Enrolling twice is refused — see enrollFace. + */ +meRouter.post( + "/face/enroll", + requirePermission("self:punch"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const { embedding } = parseBody(req, embeddingSchema); + await enrollFace(auth.companyId, auth.employeeId, embedding); + res.status(201).json({ data: { faceEnrolled: true } }); + }), +); diff --git a/backend/functions/src/routes/notifications.ts b/backend/functions/src/routes/notifications.ts new file mode 100644 index 0000000..284f003 --- /dev/null +++ b/backend/functions/src/routes/notifications.ts @@ -0,0 +1,49 @@ +import { Router } from "express"; +import { asyncHandler } from "../lib/errors"; +import { authOf } from "../middleware/auth"; +import { + listNotifications, + markAllRead, + markRead, + unreadCount, +} from "../services/notifications"; + +/** + * What the signed-in person needs to be told. + * + * No permission gate: a notification is addressed to one employee and every + * handler is scoped to the caller's own id. There is nothing here that being + * a manager should let somebody see more of, and nothing an employee should be + * refused about their own leave. + */ +export const notificationsRouter = Router(); + +notificationsRouter.get( + "/", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const [items, unread] = await Promise.all([ + listNotifications(auth.companyId, auth.employeeId), + unreadCount(auth.companyId, auth.employeeId), + ]); + res.json({ data: { items, unread } }); + }), +); + +notificationsRouter.post( + "/:id/read", + asyncHandler(async (req, res) => { + const auth = authOf(req); + await markRead(auth.companyId, auth.employeeId, req.params.id); + res.json({ data: { id: req.params.id, read: true } }); + }), +); + +notificationsRouter.post( + "/read-all", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const marked = await markAllRead(auth.companyId, auth.employeeId); + res.json({ data: { marked } }); + }), +); diff --git a/backend/functions/src/routes/payroll.ts b/backend/functions/src/routes/payroll.ts new file mode 100644 index 0000000..768e824 --- /dev/null +++ b/backend/functions/src/routes/payroll.ts @@ -0,0 +1,416 @@ +import { Router } from "express"; +import { + assignmentId, + assignmentWriteSchema, + clearAssignment, + listAssignmentsFor, + setAssignment, +} from "../services/salaryAssignments"; +import { Timestamp } from "firebase-admin/firestore"; +import { z } from "zod"; +import { ApiError, ErrorCodes, asyncHandler } from "../lib/errors"; +import { audit, db, nowTimestamp, tenant, toIso } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { withIdempotency } from "../middleware/idempotency"; +import { parseBody } from "../middleware/validate"; +import { ulid } from "../lib/ids"; +import { computePayrollRun } from "../services/payroll"; +import { getSettings } from "../services/settings"; + +export const payrollRouter = Router(); + +/** Payroll runs for this company, newest first. */ +payrollRouter.get( + "/runs", + requirePermission("payroll:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const snapshot = await tenant(auth.companyId, "payrollRuns").get(); + const runs = snapshot.docs + .map((doc) => { + const d = doc.data() as Record; + return { + id: doc.id, + periodYear: (d.periodYear as number) ?? 0, + periodMonth: (d.periodMonth as number) ?? 0, + status: (d.status as string) ?? "APPROVED", + currency: (d.currency as string) ?? "AFN", + payslipCount: (d.payslipCount as number) ?? 0, + totalGross: (d.totalGross as number) ?? 0, + totalNet: (d.totalNet as number) ?? 0, + totalTax: (d.totalTax as number) ?? 0, + totalEmployerCost: (d.totalEmployerCost as number) ?? 0, + // Runs written before this field existed were all whole months. + periodComplete: (d.periodComplete as boolean | undefined) ?? true, + lockedAt: toIso((d.lockedAt as Timestamp | null | undefined) ?? null), + createdAt: toIso((d.createdAt as Timestamp | null | undefined) ?? null), + }; + }) + .sort((a, b) => b.periodYear * 100 + b.periodMonth - (a.periodYear * 100 + a.periodMonth)); + res.json({ data: runs }); + }), +); + +const runCreateSchema = z.object({ + periodYear: z.number().int().min(1300).max(1500), + periodMonth: z.number().int().min(1).max(12), +}); + +/** Run (compute) payroll for a Solar Hijri month. Idempotent per period. */ +payrollRouter.post( + "/runs", + requirePermission("payroll:run"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const { periodYear, periodMonth } = parseBody(req, runCreateSchema); + + const { result, replayed } = await withIdempotency( + auth.companyId, + req.header("Idempotency-Key"), + async () => { + const companySnap = await db.collection("companies").doc(auth.companyId).get(); + const currency = (companySnap.data()?.currency as string | undefined) ?? "AFN"; + + return computePayrollRun( + auth.companyId, + periodYear, + periodMonth, + auth.employeeId, + currency, + ); + }, + ); + + res.status(replayed ? 200 : 201).json({ data: result }); + }), +); + +/** Payslips generated in a run (manager view across employees). */ +payrollRouter.get( + "/runs/:runId/payslips", + requirePermission("payroll:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const runSnap = await tenant(auth.companyId, "payrollRuns").doc(req.params.runId).get(); + if (!runSnap.exists) { + throw ApiError.notFound("Payroll run not found"); + } + const snapshot = await tenant(auth.companyId, "payslips") + .where("runId", "==", req.params.runId) + .get(); + + // Join employee names for the table (small runs; batched in prod). + const rows = await Promise.all( + snapshot.docs.map(async (doc) => { + const d = doc.data() as Record; + const empSnap = await tenant(auth.companyId, "employees") + .doc(d.employeeId as string) + .get(); + const emp = empSnap.data() as + | { firstName?: string; lastName?: string; employeeCode?: string } + | undefined; + return { + id: doc.id, + employeeId: d.employeeId, + // The printed payment sheet is keyed by the code, not the name: two + // people called احمد in one company is the ordinary case, and a sheet + // somebody signs has to be unambiguous about who signed which line. + employeeCode: emp?.employeeCode ?? "", + employeeName: emp ? `${emp.firstName ?? ""} ${emp.lastName ?? ""}`.trim() : d.employeeId, + currency: d.currency, + gross: d.gross, + totalDeductions: d.totalDeductions, + net: d.net, + incomeTax: d.incomeTax ?? 0, + employerCost: d.employerCost ?? 0, + costToCompany: d.costToCompany ?? d.gross, + workedDays: d.workedDays, + lopDays: d.lopDays, + status: d.status, + }; + }), + ); + rows.sort((a, b) => String(a.employeeName).localeCompare(String(b.employeeName))); + res.json({ data: { runId: req.params.runId, payslips: rows } }); + }), +); + +// ------------------------------------------------------ salary configuration + +/* + * Payroll reads employeeSalaries and salaryComponents, and until now nothing + * could write either: the demo seed was their only author. On a company that + * signed up for itself, every run returned 200 with payslipCount 0 because + * `computePayrollRun` skips an employee with no salary on file. These are the + * missing halves. + * + * Compensation sits with whoever runs payroll: reading needs payroll:read, + * writing needs payroll:run. A COMPANY_ADMIN holds both through the wildcard. + */ + +const salarySchema = z.object({ + /** + * The rate. What it is a rate FOR depends on payModel: a monthly salary, a + * day's wage, or the price of one piece. One field rather than three, so + * there is nothing to keep consistent — see services/payModels.ts. + */ + basicAmount: z.number().min(0).max(100_000_000), + // Optional so every existing client keeps working; absent means MONTHLY, + // which is what every company on file today is. + payModel: z.enum(["MONTHLY", "DAILY", "PIECE"]).optional(), + effectiveFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD"), + revisionReason: z.string().max(200).nullish(), +}); + +/** The employee's current basic salary, or null when none is on file yet. */ +payrollRouter.get( + "/employees/:id/salary", + requirePermission("payroll:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const snap = await tenant(auth.companyId, "employeeSalaries").doc(req.params.id).get(); + if (!snap.exists) { + res.json({ data: null }); + return; + } + const d = snap.data() as { + basicAmount?: number; + payModel?: string; + currency?: string; + effectiveFrom?: string; + revisionReason?: string | null; + updatedAt?: Timestamp; + }; + res.json({ + data: { + employeeId: req.params.id, + basicAmount: d.basicAmount ?? 0, + payModel: d.payModel ?? "MONTHLY", + currency: d.currency ?? "AFN", + effectiveFrom: d.effectiveFrom ?? null, + revisionReason: d.revisionReason ?? null, + updatedAt: toIso(d.updatedAt ?? null), + }, + }); + }), +); + +/** Sets or revises the employee's basic salary. */ +payrollRouter.put( + "/employees/:id/salary", + requirePermission("payroll:run"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, salarySchema); + + const employeeRef = tenant(auth.companyId, "employees").doc(req.params.id); + if (!(await employeeRef.get()).exists) { + throw ApiError.notFound("Employee not found"); + } + + const ref = tenant(auth.companyId, "employeeSalaries").doc(req.params.id); + const before = (await ref.get()).data() ?? null; + const { profile } = await getSettings(auth.companyId); + + const doc = { + employeeId: req.params.id, + structureId: null, + basicAmount: payload.basicAmount, + // Omitting it on an edit keeps the model already on file rather than + // silently moving somebody back to a monthly salary. + payModel: payload.payModel ?? (before?.payModel as string | undefined) ?? "MONTHLY", + currency: profile.currency, + effectiveFrom: payload.effectiveFrom, + revisionReason: payload.revisionReason ?? null, + updatedAt: nowTimestamp(), + }; + await ref.set(doc, { merge: true }); + + // Pay is the kind of change that has to be answerable for later. + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "payroll.salary.set", + resourceType: "employeeSalaries", + resourceId: req.params.id, + before, + after: doc, + }); + + res.json({ data: { ...doc, updatedAt: toIso(doc.updatedAt) } }); + }), +); + +const componentSchema = z.object({ + name: z.string().min(1).max(80), + code: z.string().min(1).max(24).regex(/^[A-Z0-9_]+$/, "Use A–Z, 0–9 and underscore"), + type: z.enum(["EARNING", "DEDUCTION", "EMPLOYER_COST"]), + calc: z.enum(["FIXED", "PERCENT_OF_BASIC", "PERCENT_OF_GROSS"]), + value: z.number().min(0).max(100_000_000), + // Defaults to taxable: Afghan income tax treats salary and most allowances + // as part of the base, and defaulting the other way silently under-withholds. + taxable: z.boolean().optional().default(true), + // Defaults to ALL, which is what every component did before this existed. + scope: z.enum(["ALL", "INDIVIDUAL"]).optional().default("ALL"), + active: z.boolean().optional().default(true), +}); + +/** Allowances, deductions and employer costs applied to every payslip. */ +payrollRouter.get( + "/components", + requirePermission("payroll:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const snap = await tenant(auth.companyId, "salaryComponents").limit(200).get(); + res.json({ + data: snap.docs.map((doc) => { + const d = doc.data(); + return { + id: doc.id, + name: d.name, + code: d.code, + type: d.type, + calc: d.calc, + value: d.value, + taxable: d.taxable ?? true, + // Components written before individual assignment applied to all. + scope: d.scope ?? "ALL", + active: d.active ?? true, + }; + }), + }); + }), +); + +payrollRouter.post( + "/components", + requirePermission("payroll:run"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, componentSchema); + + // The code identifies the line on every payslip, so it has to stay unique. + const clash = await tenant(auth.companyId, "salaryComponents") + .where("code", "==", payload.code) + .limit(1) + .get(); + if (!clash.empty) { + throw ApiError.business(ErrorCodes.CONFLICT, `A component with code ${payload.code} exists`); + } + + const id = ulid(); + const doc = { companyId: auth.companyId, ...payload, updatedAt: nowTimestamp() }; + await tenant(auth.companyId, "salaryComponents").doc(id).create(doc); + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "payroll.component.create", + resourceType: "salaryComponents", + resourceId: id, + after: doc, + }); + res.status(201).json({ data: { id, ...payload } }); + }), +); + +payrollRouter.put( + "/components/:id", + requirePermission("payroll:run"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, componentSchema); + const ref = tenant(auth.companyId, "salaryComponents").doc(req.params.id); + const existing = await ref.get(); + if (!existing.exists) { + throw ApiError.notFound("Salary component not found"); + } + + const clash = await tenant(auth.companyId, "salaryComponents") + .where("code", "==", payload.code) + .limit(2) + .get(); + if (clash.docs.some((d) => d.id !== req.params.id)) { + throw ApiError.business(ErrorCodes.CONFLICT, `A component with code ${payload.code} exists`); + } + + const doc = { companyId: auth.companyId, ...payload, updatedAt: nowTimestamp() }; + await ref.set(doc); + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "payroll.component.update", + resourceType: "salaryComponents", + resourceId: req.params.id, + before: existing.data(), + after: doc, + }); + res.json({ data: { id: req.params.id, ...payload } }); + }), +); + +/** + * Which components apply to one employee, and at what amount. + * + * Read with payroll:read and written with payroll:run, the same as the + * component definitions themselves — assigning an allowance is a pay decision, + * not an employee-record edit. + */ +payrollRouter.get( + "/employees/:employeeId/components", + requirePermission("payroll:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + res.json({ data: await listAssignmentsFor(auth.companyId, req.params.employeeId) }); + }), +); + +payrollRouter.put( + "/employees/:employeeId/components/:componentId", + requirePermission("payroll:run"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const { employeeId, componentId } = req.params; + const payload = parseBody(req, assignmentWriteSchema); + + // Refuse to point at things that are not there: an assignment against a + // deleted component or a mistyped employee id would sit in the collection + // doing nothing, and would be found only when someone's pay looked wrong. + const [employee, component] = await Promise.all([ + tenant(auth.companyId, "employees").doc(employeeId).get(), + tenant(auth.companyId, "salaryComponents").doc(componentId).get(), + ]); + if (!employee.exists) throw ApiError.notFound("Employee not found"); + if (!component.exists) throw ApiError.notFound("Salary component not found"); + + const assignment = await setAssignment(auth.companyId, employeeId, componentId, payload); + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "payroll.assignment.set", + resourceType: "employeeComponents", + resourceId: assignmentId(employeeId, componentId), + after: assignment, + }); + res.json({ data: assignment }); + }), +); + +/** Returns the employee to whatever the component itself does. */ +payrollRouter.delete( + "/employees/:employeeId/components/:componentId", + requirePermission("payroll:run"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const { employeeId, componentId } = req.params; + await clearAssignment(auth.companyId, employeeId, componentId); + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "payroll.assignment.clear", + resourceType: "employeeComponents", + resourceId: assignmentId(employeeId, componentId), + }); + res.status(204).send(); + }), +); diff --git a/backend/functions/src/routes/payslips.ts b/backend/functions/src/routes/payslips.ts new file mode 100644 index 0000000..8ce1937 --- /dev/null +++ b/backend/functions/src/routes/payslips.ts @@ -0,0 +1,36 @@ +import { Router } from "express"; +import { Timestamp } from "firebase-admin/firestore"; +import { ApiError, asyncHandler } from "../lib/errors"; +import { tenant, toIso } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; + +export const payslipsRouter = Router(); + +/** Self-service payslips for one year. Finalized/paid slips only. */ +payslipsRouter.get( + "/", + asyncHandler(async (req, res) => { + const auth = authOf(req); + // periodYear is a Solar Hijri year (payroll.ts writes 1405, not 2026), so a + // Gregorian range rejected every request the app has ever made. + const year = Number.parseInt(String(req.query.year ?? ""), 10); + if (Number.isNaN(year) || year < 1300 || year > 1500) { + throw ApiError.validation("year must be a Solar Hijri year (1300–1500)"); + } + + const snapshot = await tenant(auth.companyId, "payslips") + .where("employeeId", "==", auth.employeeId) + .where("periodYear", "==", year) + .get(); + + res.json({ + data: snapshot.docs + .map((doc) => ({ id: doc.id, ...doc.data() })) + .filter((slip) => (slip as { status?: string }).status !== "DRAFT") + .map((slip) => ({ + ...slip, + updatedAt: toIso((slip as { updatedAt?: Timestamp }).updatedAt ?? null), + })), + }); + }), +); diff --git a/backend/functions/src/routes/pieceWork.ts b/backend/functions/src/routes/pieceWork.ts new file mode 100644 index 0000000..b016fab --- /dev/null +++ b/backend/functions/src/routes/pieceWork.ts @@ -0,0 +1,104 @@ +import { Router } from "express"; +import { z } from "zod"; +import { asyncHandler } from "../lib/errors"; +import { audit, tenant } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { parseBody } from "../middleware/validate"; +import { + deletePieceRecord, + pieceRecordToDto, + recordPieces, + type PieceRecordDoc, +} from "../services/pieceWork"; + +/** + * What piece-rate workers finished. + * + * Gated on payroll:run for writes: a piece count IS the wage for anybody on + * that model, so recording one is the same kind of act as setting a salary. + */ +export const pieceWorkRouter = Router(); + +const createSchema = z.object({ + employeeId: z.string().min(1), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD"), + // Fractions allowed: half a garment finished at month end is a real thing in + // a workshop, and forcing whole numbers would push it into the next month. + quantity: z.number().positive().max(1_000_000), + note: z.string().max(200).nullish(), +}); + +pieceWorkRouter.get( + "/", + requirePermission("payroll:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const employeeId = req.query.employeeId ? String(req.query.employeeId) : null; + + const base = tenant(auth.companyId, "pieceRecords"); + const query = employeeId + ? base.where("employeeId", "==", employeeId).orderBy("date", "desc").limit(300) + : base.orderBy("date", "desc").limit(300); + + const snap = await query.get(); + res.json({ data: snap.docs.map((d) => pieceRecordToDto(d.id, d.data() as PieceRecordDoc)) }); + }), +); + +pieceWorkRouter.post( + "/", + requirePermission("payroll:run"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, createSchema); + + const { id, doc } = await recordPieces( + auth.companyId, + { + employeeId: payload.employeeId, + date: payload.date, + quantity: payload.quantity, + note: payload.note ?? null, + }, + auth.employeeId, + ); + + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "pieceWork.create", + resourceType: "pieceRecords", + resourceId: id, + after: { employeeId: payload.employeeId, quantity: payload.quantity, date: payload.date }, + }); + + res.status(201).json({ data: pieceRecordToDto(id, doc) }); + }), +); + +/** + * Removes a miscounted entry. + * + * A real delete rather than a cancellation, unlike an advance: nothing was + * handed to anybody, and a workshop correcting "40" to "35" an hour later + * should not leave two rows for one day's work to argue over. + */ +pieceWorkRouter.delete( + "/:id", + requirePermission("payroll:run"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + await deletePieceRecord(auth.companyId, req.params.id); + + await audit(auth.companyId, { + actorId: auth.employeeId, + actorRole: auth.roles.join(","), + action: "pieceWork.delete", + resourceType: "pieceRecords", + resourceId: req.params.id, + }); + + res.json({ data: { id: req.params.id } }); + }), +); diff --git a/backend/functions/src/routes/public.ts b/backend/functions/src/routes/public.ts new file mode 100644 index 0000000..4379060 --- /dev/null +++ b/backend/functions/src/routes/public.ts @@ -0,0 +1,60 @@ +import { Router } from "express"; +import { asyncHandler } from "../lib/errors"; +import { + clientAddress, + enforceRateLimit, + type RateLimitRule, +} from "../middleware/rateLimit"; +import { parseBody } from "../middleware/validate"; +import { companySignupSchema, provisionCompany } from "../services/signup"; + +/** + * Unauthenticated routes (mounted before the auth middleware). Keep this + * surface minimal — only self-service company signup lives here. + */ +export const publicRouter = Router(); + +const HOUR = 60 * 60 * 1000; + +/** + * Signup creates a Firebase Auth user and eight Firestore documents per call, + * with no credential required, so it is throttled three ways. + * + * The per-email limit is the one that protects a person: an attacker trying to + * sign someone else's mailbox up repeatedly cannot vary that address. The + * global limit is the one that protects the project — nothing a caller controls + * can bypass it, so it bounds the worst case regardless of spoofing. The + * per-address limit only spreads honest traffic; X-Forwarded-For is + * caller-supplied and is not a security boundary on its own. + */ +const PER_EMAIL: RateLimitRule = { bucket: "signup_email", limit: 3, windowMs: HOUR }; +const PER_ADDRESS: RateLimitRule = { bucket: "signup_addr", limit: 10, windowMs: HOUR }; +const GLOBAL: RateLimitRule = { bucket: "signup_global", limit: 60, windowMs: HOUR }; + +publicRouter.post( + "/signup", + asyncHandler(async (req, res) => { + // Charged before the body is parsed, so malformed floods are throttled too. + await enforceRateLimit( + PER_ADDRESS, + clientAddress(req), + "Too many signup attempts from this network. Try again later.", + ); + await enforceRateLimit( + GLOBAL, + "all", + "Signups are temporarily throttled. Try again later.", + ); + + const input = parseBody(req, companySignupSchema); + + await enforceRateLimit( + PER_EMAIL, + input.email, + "Too many signup attempts for this email address. Try again later.", + ); + + const result = await provisionCompany(input); + res.status(201).json({ data: result }); + }), +); diff --git a/backend/functions/src/routes/settings.ts b/backend/functions/src/routes/settings.ts new file mode 100644 index 0000000..5dea227 --- /dev/null +++ b/backend/functions/src/routes/settings.ts @@ -0,0 +1,29 @@ +import { Router } from "express"; +import { asyncHandler } from "../lib/errors"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { parseBody } from "../middleware/validate"; +import { getSettings, settingsUpdateSchema, updateSettings } from "../services/settings"; + +export const settingsRouter = Router(); + +/** Any authenticated member may read settings (apps gate UI on the feature flags). */ +settingsRouter.get( + "/", + asyncHandler(async (req, res) => { + const auth = authOf(req); + res.json({ data: await getSettings(auth.companyId) }); + }), +); + +/** Only the company admin edits configuration (the dedicated-admin surface). */ +settingsRouter.put( + "/", + requirePermission("settings:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const patch = parseBody(req, settingsUpdateSchema); + const next = await updateSettings(auth.companyId, patch, auth.employeeId, auth.roles); + res.json({ data: next }); + }), +); diff --git a/backend/functions/src/routes/shifts.ts b/backend/functions/src/routes/shifts.ts new file mode 100644 index 0000000..b3b89a1 --- /dev/null +++ b/backend/functions/src/routes/shifts.ts @@ -0,0 +1,73 @@ +import { Router } from "express"; +import { ApiError, asyncHandler } from "../lib/errors"; +import { authOf } from "../middleware/auth"; +import { requirePermission } from "../middleware/rbac"; +import { parseBody } from "../middleware/validate"; +import { + assignRoster, + createShift, + listShifts, + rosterAssignSchema, + rosterForDate, + shiftWriteSchema, + updateShift, +} from "../services/shifts"; + +export const shiftsRouter = Router(); + +/** List shift definitions (any authed member; the apps render today's shift). */ +shiftsRouter.get( + "/", + asyncHandler(async (req, res) => { + const auth = authOf(req); + res.json({ data: await listShifts(auth.companyId) }); + }), +); + +shiftsRouter.post( + "/", + requirePermission("rosters:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, shiftWriteSchema); + const dto = await createShift(auth.companyId, payload, auth.employeeId, auth.roles); + res.status(201).json({ data: dto }); + }), +); + +shiftsRouter.put( + "/:id", + requirePermission("rosters:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, shiftWriteSchema); + const dto = await updateShift(auth.companyId, req.params.id, payload, auth.employeeId, auth.roles); + res.json({ data: dto }); + }), +); + +// ------------------------------------------------------------------ roster + +shiftsRouter.post( + "/roster/assign", + requirePermission("rosters:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, rosterAssignSchema); + const result = await assignRoster(auth.companyId, payload, auth.employeeId, auth.roles); + res.status(201).json({ data: result }); + }), +); + +shiftsRouter.get( + "/roster", + requirePermission("rosters:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const date = String(req.query.date ?? ""); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + throw ApiError.validation("date must be an ISO date (YYYY-MM-DD)"); + } + res.json({ data: { date, rows: await rosterForDate(auth.companyId, date) } }); + }), +); diff --git a/backend/functions/src/routes/support.integration.test.ts b/backend/functions/src/routes/support.integration.test.ts new file mode 100644 index 0000000..e6c5f00 --- /dev/null +++ b/backend/functions/src/routes/support.integration.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { db } from "../lib/firestore"; + +/** + * A customer raising an issue. + * + * This is the one place a tenant writes into the vendor's own data, so most of + * these are about what they cannot do with it. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); + +const token = vi.hoisted(() => ({ claims: {} as Record })); +vi.mock("firebase-admin/auth", async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + getAuth: () => ({ + verifyIdToken: async () => { + if (!token.claims.uid) throw new Error("no token"); + return token.claims; + }, + }), + }; +}); + +const { createApp } = await import("../app"); +const app = createApp(); + +async function request( + method: string, + path: string, + body?: unknown, +): Promise<{ status: number; body: Record }> { + const { createServer } = await import("node:http"); + const server = createServer(app); + await new Promise((r) => server.listen(0, r)); + const port = (server.address() as { port: number }).port; + try { + const res = await fetch(`http://127.0.0.1:${port}${path}`, { + method, + headers: { Authorization: "Bearer t", "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : {} }; + } finally { + server.close(); + } +} + +let cidA = ""; +let cidB = ""; +let seq = 0; + +const asEmployee = (cid: string) => ({ + uid: "u_emp", + cid, + eid: "emp_1", + r: ["EMPLOYEE"], + email_verified: true, +}); + +async function wipeCrm(): Promise { + for (const c of ["crmAccounts", "crmTickets"]) { + const snap = await db.collection(c).get(); + const batch = db.batch(); + snap.docs.forEach((d) => batch.delete(d.ref)); + if (snap.size) await batch.commit(); + } +} + +describe.skipIf(!EMULATOR)("raising an issue from the product", () => { + beforeEach(async () => { + seq += 1; + cidA = `sup_a_${Date.now()}_${seq}`; + cidB = `sup_b_${Date.now()}_${seq}`; + await db.collection("companies").doc(cidA).set({ + name: "Acme Kabul", + license: { plan: "STANDARD", deviceLimit: 12, status: "ACTIVE", expiresAt: null, enforceDevices: true }, + }); + await db.collection("companies").doc(cidB).set({ name: "Beta Herat" }); + await wipeCrm(); + token.claims = asEmployee(cidA); + }); + + it("files the issue with the context the vendor would have had to ask for", async () => { + const res = await request("POST", "/v1/support/tickets", { + subject: "The app will not install on old phones", + detail: "Three of our workers have Android 7.", + }); + expect(res.status).toBe(201); + + const snap = await db.collection("crmTickets").get(); + expect(snap.size).toBe(1); + const t = snap.docs[0].data(); + expect(t.subject).toBe("The app will not install on old phones"); + expect(t.companyId).toBe(cidA); + expect(t.companyName).toBe("Acme Kabul"); + expect(t.plan).toBe("STANDARD"); // from the licence, not the request + expect(t.seats).toBe(12); + expect(t.raisedBy).toBe("emp_1"); + expect(t.source).toBe("PORTAL"); + }); + + it("files a company that the vendor has not yet put in the CRM", async () => { + // A customer writing in should not have to wait to have been filed first. + await request("POST", "/v1/support/tickets", { subject: "Help" }); + + const accounts = await db.collection("crmAccounts").get(); + expect(accounts.size).toBe(1); + expect(accounts.docs[0].data().companyId).toBe(cidA); + expect(accounts.docs[0].data().stage).toBe("WON"); + }); + + it("does not make a second account for the same company", async () => { + await request("POST", "/v1/support/tickets", { subject: "One" }); + await request("POST", "/v1/support/tickets", { subject: "Two" }); + + expect((await db.collection("crmAccounts").get()).size).toBe(1); + expect((await db.collection("crmTickets").get()).size).toBe(2); + }); + + it("ignores a status or priority the customer tries to set", async () => { + // Otherwise every ticket arrives URGENT and the column stops meaning + // anything. + await request("POST", "/v1/support/tickets", { + subject: "Urgent!", + status: "RESOLVED", + priority: "URGENT", + accountId: "somebody-elses-account", + }); + + const t = (await db.collection("crmTickets").get()).docs[0].data(); + expect(t.status).toBe("OPEN"); + expect(t.priority).toBe("NORMAL"); + expect(t.accountId).not.toBe("somebody-elses-account"); + }); + + it("shows a company only its own issues", async () => { + await request("POST", "/v1/support/tickets", { subject: "Ours" }); + token.claims = asEmployee(cidB); + await request("POST", "/v1/support/tickets", { subject: "Theirs" }); + + const mine = (await request("GET", "/v1/support/tickets")).body.data as Array< + Record + >; + expect(mine).toHaveLength(1); + expect(mine[0].subject).toBe("Theirs"); + }); + + it("gives a receipt, not a window into the vendor's notes", async () => { + await request("POST", "/v1/support/tickets", { subject: "Something" }); + // The vendor works on it privately. + const id = (await db.collection("crmTickets").get()).docs[0].id; + await db.collection("crmTickets").doc(id).set( + { priority: "URGENT", resolution: "Their own router blocks us", detail: "internal" }, + { merge: true }, + ); + + const row = ((await request("GET", "/v1/support/tickets")).body.data as Array< + Record + >)[0]; + expect(Object.keys(row).sort()).toEqual( + ["id", "openedAt", "resolvedAt", "status", "subject"].sort(), + ); + expect(JSON.stringify(row)).not.toContain("router"); + }); + + it("refuses an empty subject", async () => { + expect((await request("POST", "/v1/support/tickets", { subject: "" })).status).toBe(422); + expect((await db.collection("crmTickets").get()).size).toBe(0); + }); + + it("stops one company filling the console", async () => { + // 20 an hour is generous for a real customer and useless for a script. + let refused = 0; + for (let i = 0; i < 24; i++) { + const res = await request("POST", "/v1/support/tickets", { subject: `spam ${i}` }); + if (res.status === 429) refused += 1; + } + expect(refused).toBeGreaterThan(0); + expect((await db.collection("crmTickets").get()).size).toBeLessThanOrEqual(20); + }); + + it("is closed to anyone without a token", async () => { + token.claims = {}; + expect((await request("POST", "/v1/support/tickets", { subject: "x" })).status).toBe(401); + expect((await request("GET", "/v1/support/tickets")).status).toBe(401); + }); +}); diff --git a/backend/functions/src/routes/support.ts b/backend/functions/src/routes/support.ts new file mode 100644 index 0000000..bde16f8 --- /dev/null +++ b/backend/functions/src/routes/support.ts @@ -0,0 +1,143 @@ +import { Router } from "express"; +import { z } from "zod"; +import { asyncHandler } from "../lib/errors"; +import { db, nowTimestamp, toIso } from "../lib/firestore"; +import { ulid } from "../lib/ids"; +import { authOf } from "../middleware/auth"; +import { enforceRateLimit, type RateLimitRule } from "../middleware/rateLimit"; +import { parseBody } from "../middleware/validate"; +import { getLicense } from "../services/license"; + +/** + * How a customer reaches the vendor from inside the product. + * + * Until this existed they could only phone or email, and the vendor retyped + * what they said into the console — losing the one thing that makes a support + * message useful: which company, on what plan, running what. All of that is + * attached here from the token and the licence, never from the request. + * + * This writes into the vendor's own CRM, which is the one place a tenant is + * otherwise never allowed to touch. Three things keep that narrow: + * + * - the company is taken from the caller's token, exactly like every other + * tenant route, so nobody can file against somebody else; + * - reads are filtered to the caller's own company, and there is no route + * here that returns anything else; + * - status, priority and the account link are the vendor's to set. A + * customer cannot mark their own ticket urgent, or resolved. + */ +export const supportRouter = Router(); + +const ticketCreateSchema = z.object({ + subject: z.string().min(1).max(200), + detail: z.string().max(4000).optional(), +}); + +/** A tenant must not be able to fill the vendor's console. */ +const PER_COMPANY: RateLimitRule = { bucket: "support", limit: 20, windowMs: 3_600_000 }; + +/** + * The CRM account this company belongs to, created if the vendor has not made + * one yet. + * + * A customer who writes in should not have to wait for the vendor to have + * filed them first — and this quietly closes the gap where real customers were + * missing from the pipeline entirely. + */ +async function accountFor(companyId: string, companyName: string): Promise { + const existing = await db + .collection("crmAccounts") + .where("companyId", "==", companyId) + .limit(1) + .get(); + if (!existing.empty) return existing.docs[0].id; + + const id = ulid(); + const now = nowTimestamp(); + await db.collection("crmAccounts").doc(id).set({ + name: companyName, + stage: "WON", // they are a live tenant; they are not a lead + companyId, + createdBy: "support", + createdAt: now, + updatedAt: now, + }); + return id; +} + +/** Raise an issue. */ +supportRouter.post( + "/tickets", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, ticketCreateSchema); + + await enforceRateLimit( + PER_COMPANY, + `support:${auth.companyId}`, + "Too many issues raised in the last hour. Call us if it is urgent.", + ); + + const [companySnap, license] = await Promise.all([ + db.collection("companies").doc(auth.companyId).get(), + getLicense(auth.companyId), + ]); + const companyName = (companySnap.data()?.name as string) ?? auth.companyId; + + const id = ulid(); + const now = nowTimestamp(); + await db.collection("crmTickets").doc(id).set({ + accountId: await accountFor(auth.companyId, companyName), + subject: payload.subject, + detail: payload.detail ?? null, + // The vendor's to grade. A customer marking their own issue URGENT would + // make the priority column meaningless within a week. + status: "OPEN", + priority: "NORMAL", + openedAt: toIso(now)?.slice(0, 10) ?? null, + resolvedAt: null, + // The context the vendor would otherwise have to ask for. + raisedBy: auth.employeeId, + companyId: auth.companyId, + companyName, + plan: license.plan, + seats: license.deviceLimit, + source: "PORTAL", + createdBy: auth.employeeId, + createdAt: now, + updatedAt: now, + }); + + res.status(201).json({ data: { id, subject: payload.subject, status: "OPEN" } }); + }), +); + +/** The caller's own company's issues, so they can see one was received. */ +supportRouter.get( + "/tickets", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const snap = await db + .collection("crmTickets") + .where("companyId", "==", auth.companyId) + .limit(100) + .get(); + + res.json({ + data: snap.docs + .map((d) => { + const v = d.data(); + return { + id: d.id, + subject: v.subject as string, + status: v.status as string, + openedAt: (v.openedAt as string) ?? null, + resolvedAt: (v.resolvedAt as string) ?? null, + // Deliberately not the vendor's internal notes, priority or + // resolution text — this is a receipt, not a window into the CRM. + }; + }) + .sort((a, b) => String(b.openedAt).localeCompare(String(a.openedAt))), + }); + }), +); diff --git a/backend/functions/src/routes/sync.ts b/backend/functions/src/routes/sync.ts new file mode 100644 index 0000000..5e2f024 --- /dev/null +++ b/backend/functions/src/routes/sync.ts @@ -0,0 +1,242 @@ +import { Router } from "express"; +import { FieldPath, Timestamp } from "firebase-admin/firestore"; +import type { Query } from "firebase-admin/firestore"; +import { z } from "zod"; +import { ApiError, ErrorCodes } from "../lib/errors"; +import { asyncHandler } from "../lib/errors"; +import { tenant } from "../lib/firestore"; +import type { TenantCollection } from "../lib/firestore"; +import { authOf } from "../middleware/auth"; +import { parseBody, parsePayload } from "../middleware/validate"; +import { applyPunch, punchCreateSchema } from "../services/punch"; +import { createLeaveRequest, leaveCreateSchema } from "../services/leave"; +import { createRegularization, regularizationCreateSchema } from "../services/regularization"; +import { kioskSecret } from "../config"; + +export const syncRouter = Router(); + +// ---------------------------------------------------------------------- push + +const syncOpSchema = z.object({ + opId: z.string().min(1), + opType: z.enum(["CREATE", "UPDATE", "DELETE"]), + resourceType: z.string().min(1), + resourceId: z.string().min(1), + idempotencyKey: z.string().min(1), + payload: z.record(z.unknown()), +}); + +const syncPushSchema = z.object({ + ops: z.array(syncOpSchema).min(1).max(100), +}); + +/** + * Batched outbox drain. Each op is applied independently and idempotently + * (client-generated ULIDs make replays no-ops); business rejections come back + * as per-op REJECTED results, never batch failures. + */ +syncRouter.post( + "/push", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const { ops } = parseBody(req, syncPushSchema); + + const results = []; + for (const op of ops) { + try { + if (op.opType !== "CREATE") { + throw ApiError.business( + ErrorCodes.UNSUPPORTED_RESOURCE, + `${op.opType} is not supported via sync for ${op.resourceType}`, + ); + } + const resource = await applyOp(auth.companyId, auth.employeeId, op.resourceType, op.payload); + results.push({ opId: op.opId, status: "APPLIED", resource }); + } catch (err) { + if (err instanceof ApiError) { + results.push({ + opId: op.opId, + status: "REJECTED", + errorCode: err.code, + message: err.detail, + }); + } else { + // Infrastructure failure: fail the batch so the client retries it whole. + throw err; + } + } + } + res.json({ data: { results } }); + }), +); + +async function applyOp( + cid: string, + employeeId: string, + resourceType: string, + payload: Record, +): Promise> { + switch (resourceType) { + case "punches": + return applyPunch(cid, employeeId, parsePayload(payload, punchCreateSchema), kioskSecret.value()); + case "leaveRequests": + return createLeaveRequest(cid, employeeId, parsePayload(payload, leaveCreateSchema)); + case "regularizations": + return createRegularization(cid, employeeId, parsePayload(payload, regularizationCreateSchema)); + default: + throw ApiError.business( + ErrorCodes.UNSUPPORTED_RESOURCE, + `Resource type ${resourceType} cannot be pushed`, + ); + } +} + +// ---------------------------------------------------------------------- pull + +/** How each resource type is scoped for delta pull. */ +type PullScope = "company" | "employee" | "employeeOrApprover" | "self" | "assignee"; + +const PULL_REGISTRY: Record = { + branches: { collection: "branches", scope: "company" }, + geofences: { collection: "geofences", scope: "company" }, + employees: { collection: "employees", scope: "self" }, + shifts: { collection: "shifts", scope: "company" }, + shiftAssignments: { collection: "shiftAssignments", scope: "employee" }, + leaveTypes: { collection: "leaveTypes", scope: "company" }, + leaveBalances: { collection: "leaveBalances", scope: "employee" }, + leaveRequests: { collection: "leaveRequests", scope: "employeeOrApprover" }, + regularizations: { collection: "regularizations", scope: "employeeOrApprover" }, + punches: { collection: "punches", scope: "employee" }, + attendanceDays: { collection: "attendanceDays", scope: "employee" }, + payslips: { collection: "payslips", scope: "employee" }, + announcements: { collection: "announcements", scope: "company" }, + projects: { collection: "projects", scope: "company" }, + tasks: { collection: "tasks", scope: "assignee" }, +}; + +/** + * Delta pull for one resource type. Cursor = `${updatedAtMillis}_${docId}`; + * pagination orders by (updatedAt, __name__) so equal timestamps never skip. + */ +syncRouter.get( + "/pull", + asyncHandler(async (req, res) => { + const auth = authOf(req); + const type = String(req.query.type ?? ""); + const entry = PULL_REGISTRY[type]; + if (!entry) { + throw ApiError.validation(`Unknown resource type '${type}'`); + } + const limit = Math.min(Number.parseInt(String(req.query.limit ?? "500"), 10) || 500, 500); + const cursor = req.query.cursor ? String(req.query.cursor) : null; + + const queries = buildQueries(auth.companyId, auth.employeeId, entry); + + // Merge the (1-2) scoped queries client-side; page size stays bounded. + const docs: FirebaseFirestore.QueryDocumentSnapshot[] = []; + for (let query of queries) { + query = query.orderBy("updatedAt", "asc").orderBy(FieldPath.documentId(), "asc"); + if (cursor) { + const [millisRaw, docId] = splitCursor(cursor); + query = query.startAfter(Timestamp.fromMillis(millisRaw), docId); + } + const snap = await query.limit(limit).get(); + docs.push(...snap.docs); + } + + docs.sort((a, b) => { + const ta = (a.data().updatedAt as Timestamp).toMillis(); + const tb = (b.data().updatedAt as Timestamp).toMillis(); + return ta !== tb ? ta - tb : a.id.localeCompare(b.id); + }); + const page = dedupeById(docs).slice(0, limit); + + const last = page[page.length - 1]; + const nextCursor = last + ? `${(last.data().updatedAt as Timestamp).toMillis()}_${last.id}` + : cursor; + + res.json({ + data: { + resourceType: type, + items: page.map((doc) => serializeDoc(doc.id, doc.data())), + nextCursor, + hasMore: page.length === limit, + }, + }); + }), +); + +function buildQueries( + cid: string, + employeeId: string, + entry: { collection: TenantCollection; scope: PullScope }, +): Query[] { + const base = tenant(cid, entry.collection); + switch (entry.scope) { + case "company": + return [base]; + case "employee": + return [base.where("employeeId", "==", employeeId)]; + case "employeeOrApprover": + return [ + base.where("employeeId", "==", employeeId), + base.where("currentApproverId", "==", employeeId), + ]; + case "self": + return [base.where(FieldPath.documentId(), "==", employeeId)]; + // A task names its people directly, so one array-contains replaces the + // "which teams am I in" lookup a team-valued field would have needed. + case "assignee": + return [base.where("assigneeIds", "array-contains", employeeId)]; + } +} + +function splitCursor(cursor: string): [number, string] { + const separator = cursor.indexOf("_"); + const millis = Number.parseInt(cursor.slice(0, separator), 10); + if (separator < 0 || Number.isNaN(millis)) { + throw ApiError.validation("Malformed cursor"); + } + return [millis, cursor.slice(separator + 1)]; +} + +function dedupeById( + docs: FirebaseFirestore.QueryDocumentSnapshot[], +): FirebaseFirestore.QueryDocumentSnapshot[] { + const seen = new Set(); + return docs.filter((doc) => { + if (seen.has(doc.ref.path)) { + return false; + } + seen.add(doc.ref.path); + return true; + }); +} + +/** Doc -> wire DTO: id injected, Timestamps to ISO strings (deep). */ +function serializeDoc(id: string, data: Record): Record { + return { id, ...convertTimestamps(data) }; +} + +function convertTimestamps(value: Record): Record { + const out: Record = {}; + for (const [key, raw] of Object.entries(value)) { + if (raw instanceof Timestamp) { + out[key] = raw.toDate().toISOString(); + } else if (Array.isArray(raw)) { + out[key] = raw.map((item) => + item !== null && typeof item === "object" && !(item instanceof Timestamp) + ? convertTimestamps(item as Record) + : item instanceof Timestamp + ? item.toDate().toISOString() + : item, + ); + } else if (raw !== null && typeof raw === "object") { + out[key] = convertTimestamps(raw as Record); + } else { + out[key] = raw; + } + } + return out; +} diff --git a/backend/functions/src/routes/vendor.integration.test.ts b/backend/functions/src/routes/vendor.integration.test.ts new file mode 100644 index 0000000..b7bc1fb --- /dev/null +++ b/backend/functions/src/routes/vendor.integration.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { db, tenant } from "../lib/firestore"; + +/** + * The vendor console, through the real Express app. + * + * The middleware tests prove a customer's token cannot reach these routes. This + * proves the other half: that the routes do what they are for, that a vendor + * token cannot reach the TENANT routes, and that the reach of the console is + * bounded to company-level facts rather than anybody's staff. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); + +const token = vi.hoisted(() => ({ claims: {} as Record })); +vi.mock("firebase-admin/auth", async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + getAuth: () => ({ + verifyIdToken: async () => { + if (!token.claims.uid) throw new Error("no token"); + return token.claims; + }, + }), + }; +}); + +const { createApp } = await import("../app"); +const app = createApp(); + +/** Minimal request driver: enough to exercise the real middleware chain. */ +async function request( + method: string, + path: string, + body?: unknown, +): Promise<{ status: number; body: Record }> { + const { createServer } = await import("node:http"); + const server = createServer(app); + await new Promise((r) => server.listen(0, r)); + const port = (server.address() as { port: number }).port; + try { + const res = await fetch(`http://127.0.0.1:${port}${path}`, { + method, + headers: { Authorization: "Bearer t", "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : {} }; + } finally { + server.close(); + } +} + +const VENDOR = { uid: "vendor_1", vendor: true, email: "staff@linumic.com", email_verified: true }; +const ADMIN = (cid: string) => ({ + uid: "u_admin", + cid, + eid: "emp_1", + r: ["COMPANY_ADMIN"], + email_verified: true, +}); + +let cidA = ""; +let cidB = ""; +let seq = 0; + +describe.skipIf(!EMULATOR)("the vendor console", () => { + beforeEach(async () => { + seq += 1; + cidA = `ven_a_${Date.now()}_${seq}`; + cidB = `ven_b_${Date.now()}_${seq}`; + await db.collection("companies").doc(cidA).set({ name: "Acme Kabul", status: "ACTIVE" }); + await db.collection("companies").doc(cidB).set({ + name: "Beta Herat", + status: "ACTIVE", + license: { plan: "STANDARD", deviceLimit: 3, status: "ACTIVE", expiresAt: "2020-01-01", enforceDevices: true }, + }); + await tenant(cidA, "employees").doc("e1").set({ firstName: "A", lastName: "B", status: "ACTIVE" }); + await tenant(cidA, "devices").doc("d1").set({ status: "ACTIVE", type: "MOBILE" }); + await tenant(cidA, "devices").doc("d2").set({ status: "REVOKED", type: "MOBILE" }); + token.claims = { ...VENDOR }; + }); + + it("lists every company with its licence and seat usage", async () => { + const res = await request("GET", "/v1/vendor/companies"); + expect(res.status).toBe(200); + + const rows = res.body.data as Array>; + const a = rows.find((r) => r.companyId === cidA)!; + expect(a.name).toBe("Acme Kabul"); + expect(a.employeeCount).toBe(1); + expect(a.devicesInUse).toBe(1); // the revoked one does not hold a seat + expect((a.license as Record).deviceLimit).toBe(5); // the default + }); + + it("puts whatever is about to break first", async () => { + const rows = (await request("GET", "/v1/vendor/companies")).body.data as Array< + Record + >; + const expired = rows.findIndex((r) => r.companyId === cidB); + const fine = rows.findIndex((r) => r.companyId === cidA); + expect(expired).toBeLessThan(fine); + expect(rows[expired].daysUntilExpiry as number).toBeLessThan(0); + }); + + it("issues a licence, and the customer's own audit trail records it", async () => { + const res = await request("PUT", `/v1/vendor/companies/${cidA}/license`, { + plan: "STANDARD", + deviceLimit: 3, + status: "ACTIVE", + expiresAt: "2027-03-20", + enforceDevices: true, + }); + expect(res.status).toBe(200); + expect((res.body.data as Record).deviceLimit).toBe(3); + + const stored = (await db.collection("companies").doc(cidA).get()).data()!; + expect(stored.license.deviceLimit).toBe(3); + expect(stored.license.enforceDevices).toBe(true); + + const trail = await tenant(cidA, "auditLogs").where("action", "==", "license.update").get(); + expect(trail.size).toBe(1); + expect(trail.docs[0].data().actorRole).toBe("VENDOR"); + }); + + it("keeps its own record, outside any tenant", async () => { + // The customer's copy dies with their tenant; this one is the vendor's. + await request("PUT", `/v1/vendor/companies/${cidA}/license`, { + plan: "FREE", deviceLimit: 1, status: "ACTIVE", expiresAt: null, enforceDevices: false, + }); + + const log = await db.collection("vendorAuditLogs").where("companyId", "==", cidA).get(); + expect(log.size).toBe(1); + const entry = log.docs[0].data(); + expect(entry.actorEmail).toBe("staff@linumic.com"); + expect(entry.before.deviceLimit).toBe(5); + expect(entry.after.deviceLimit).toBe(1); + }); + + it("refuses a licence for a company that does not exist", async () => { + const res = await request("PUT", "/v1/vendor/companies/no_such_company/license", { + plan: "FREE", deviceLimit: 1, status: "ACTIVE", expiresAt: null, enforceDevices: false, + }); + expect(res.status).toBe(404); + }); + + it("validates the licence body rather than storing anything sent", async () => { + const res = await request("PUT", `/v1/vendor/companies/${cidA}/license`, { + plan: "UNLIMITED", deviceLimit: -5, status: "WHATEVER", enforceDevices: "yes", + }); + expect(res.status).toBe(422); // the house convention for a bad body + const stored = (await db.collection("companies").doc(cidA).get()).data()!; + expect(stored.license).toBeUndefined(); + }); + + it("shuts a customer's token out of the console entirely", async () => { + token.claims = ADMIN(cidA); + for (const [method, path] of [ + ["GET", "/v1/vendor/companies"], + ["GET", `/v1/vendor/companies/${cidB}`], + ["GET", "/v1/vendor/audit"], + ["GET", "/v1/vendor/me"], + ] as const) { + const res = await request(method, path); + expect(res.status, `${method} ${path}`).toBe(403); + } + const write = await request("PUT", `/v1/vendor/companies/${cidB}/license`, { + plan: "ENTERPRISE", deviceLimit: 99999, status: "ACTIVE", expiresAt: null, enforceDevices: false, + }); + expect(write.status).toBe(403); + const untouched = (await db.collection("companies").doc(cidB).get()).data()!; + expect(untouched.license.deviceLimit).toBe(3); + }); + + it("shuts a vendor token out of the tenant routes", async () => { + // The other direction: cross-tenant authority must not become the ability + // to act inside one company through the ordinary API. + token.claims = { ...VENDOR }; + for (const path of ["/v1/me", "/v1/employees", "/v1/payroll/components"]) { + const res = await request("GET", path); + expect([401, 403], path).toContain(res.status); + } + }); + + it("does not expose anybody's staff", async () => { + // The console is bounded to company-level facts by what it queries. If that + // ever changes, the privacy notice stops being true. + const body = JSON.stringify((await request("GET", "/v1/vendor/companies")).body); + expect(body).not.toContain("emp_1"); + expect(body).not.toMatch(/firstName|lastName|payslip|attendance/i); + }); +}); diff --git a/backend/functions/src/routes/vendor.ts b/backend/functions/src/routes/vendor.ts new file mode 100644 index 0000000..0a4dc56 --- /dev/null +++ b/backend/functions/src/routes/vendor.ts @@ -0,0 +1,264 @@ +import { Router } from "express"; +import type { Request } from "express"; +import { asyncHandler, ApiError } from "../lib/errors"; +import { audit, db, nowTimestamp } from "../lib/firestore"; +import { parseBody } from "../middleware/validate"; +import { requireVendor, vendorOf } from "../middleware/vendor"; +import { licenseWriteSchema, setLicense, getLicense } from "../services/license"; +import { getCompany, listCompanies } from "../services/vendor"; +import * as crm from "../services/crm"; +import { + accountWriteSchema, + activityWriteSchema, + contactWriteSchema, + dealWriteSchema, + invoiceWriteSchema, + ticketWriteSchema, +} from "../services/crm"; +import { localDateOf } from "../services/attendance"; + +/** + * The vendor console's API: Linumic's own view across every customer. + * + * Every route here reads the company id from the URL rather than from the + * caller's token — the opposite of the rest of this API, and the reason + * requireVendor is as strict as it is. Nothing else in the product may be + * mounted on this router. + */ +export const vendorRouter = Router(); + +vendorRouter.use(requireVendor); + +/** The vendor's own date, used only to compute "days until expiry" for display. */ +function today(): string { + return localDateOf(new Date(), "Asia/Kabul"); +} + +/** + * A record of what the vendor did, kept outside every tenant. + * + * The customer's own audit trail also gets the entry — a licence change is + * something they are entitled to see — but that copy lives inside a tenant that + * can be closed and purged. This one is the vendor's, and survives it. + */ +async function vendorAudit(entry: { + actorUid: string; + actorEmail: string | null; + action: string; + companyId: string; + before?: unknown; + after?: unknown; +}): Promise { + try { + await db.collection("vendorAuditLogs").add({ + ...entry, + before: entry.before ?? null, + after: entry.after ?? null, + at: nowTimestamp(), + }); + } catch (e) { + console.error("VENDOR_AUDIT_WRITE_FAILED", { action: entry.action, error: e }); + } +} + +/** Every customer, with whatever is about to break listed first. */ +vendorRouter.get( + "/companies", + asyncHandler(async (_req, res) => { + res.json({ data: await listCompanies(today()) }); + }), +); + +vendorRouter.get( + "/companies/:companyId", + asyncHandler(async (req, res) => { + const row = await getCompany(req.params.companyId, today()); + if (!row) throw ApiError.notFound("Company not found"); + res.json({ data: row }); + }), +); + +/** + * Issue or change a licence. + * + * The same setLicense the CLI calls, so there is one implementation of what a + * licence is and the console cannot drift from the script. + */ +vendorRouter.put( + "/companies/:companyId/license", + asyncHandler(async (req, res) => { + const vendor = vendorOf(req); + const { companyId } = req.params; + const payload = parseBody(req, licenseWriteSchema); + + const company = await db.collection("companies").doc(companyId).get(); + if (!company.exists) throw ApiError.notFound("Company not found"); + + const before = await getLicense(companyId); + const after = await setLicense(companyId, payload); + + // Both trails: the customer's, because it is their licence, and the + // vendor's, because it outlives their tenant. + await Promise.all([ + audit(companyId, { + actorId: vendor.uid, + actorRole: "VENDOR", + action: "license.update", + resourceType: "companies", + resourceId: companyId, + before, + after, + }), + vendorAudit({ + actorUid: vendor.uid, + actorEmail: vendor.email, + action: "license.update", + companyId, + before, + after, + }), + ]); + + res.json({ data: after }); + }), +); + +/** What the vendor has done, newest first. */ +vendorRouter.get( + "/audit", + asyncHandler(async (_req, res) => { + const snap = await db + .collection("vendorAuditLogs") + .orderBy("at", "desc") + .limit(200) + .get(); + res.json({ + data: snap.docs.map((d) => { + const v = d.data(); + return { + id: d.id, + actorEmail: v.actorEmail ?? null, + action: v.action, + companyId: v.companyId, + before: v.before ?? null, + after: v.after ?? null, + at: v.at?.toDate?.().toISOString() ?? null, + }; + }), + }); + }), +); + +/** Confirms to the console that the token really is a vendor one. */ +vendorRouter.get( + "/me", + asyncHandler(async (req, res) => { + const v = vendorOf(req); + res.json({ data: { uid: v.uid, email: v.email, vendor: true } }); + }), +); + +/* ---------------------------------------------------------------------- CRM */ + +/** + * The vendor's own customer records. + * + * All six entities are the same shape of thing — a document with an owner, a + * schema and an optional account — so they are mounted from one table rather + * than written out six times. Divergence between them would be a bug, not a + * feature. + */ +const CRM_ENTITIES = [ + { path: "accounts", collection: "crmAccounts", schema: accountWriteSchema }, + { path: "contacts", collection: "crmContacts", schema: contactWriteSchema }, + { path: "activities", collection: "crmActivities", schema: activityWriteSchema }, + { path: "deals", collection: "crmDeals", schema: dealWriteSchema }, + { path: "invoices", collection: "crmInvoices", schema: invoiceWriteSchema }, + { path: "tickets", collection: "crmTickets", schema: ticketWriteSchema }, +] as const; + +/** What the vendor did, so a disputed figure can be traced to a person. */ +async function crmAudit( + req: Request, + action: string, + id: string, + before?: unknown, + after?: unknown, +): Promise { + const v = vendorOf(req); + await vendorAudit({ + actorUid: v.uid, + actorEmail: v.email, + action, + companyId: String((after as Record)?.companyId ?? id), + before, + after, + }); +} + +for (const entity of CRM_ENTITIES) { + const base = `/crm/${entity.path}`; + + vendorRouter.get( + base, + asyncHandler(async (req, res) => { + const accountId = req.query.accountId ? String(req.query.accountId) : undefined; + res.json({ data: await crm.list(entity.collection, accountId) }); + }), + ); + + vendorRouter.get( + `${base}/:id`, + asyncHandler(async (req, res) => { + const row = await crm.get(entity.collection, req.params.id); + if (!row) throw ApiError.notFound("Not found"); + res.json({ data: row }); + }), + ); + + vendorRouter.post( + base, + asyncHandler(async (req, res) => { + const payload = parseBody(req, entity.schema); + const row = await crm.create(entity.collection, payload, vendorOf(req).uid); + await crmAudit(req, `crm.${entity.path}.create`, String(row.id), null, row); + res.status(201).json({ data: row }); + }), + ); + + vendorRouter.put( + `${base}/:id`, + asyncHandler(async (req, res) => { + const payload = parseBody(req, entity.schema); + const before = await crm.get(entity.collection, req.params.id); + const row = await crm.update(entity.collection, req.params.id, payload, vendorOf(req).uid); + if (!row) throw ApiError.notFound("Not found"); + await crmAudit(req, `crm.${entity.path}.update`, req.params.id, before, row); + res.json({ data: row }); + }), + ); + + vendorRouter.delete( + `${base}/:id`, + asyncHandler(async (req, res) => { + const before = await crm.get(entity.collection, req.params.id); + // Deleting an account takes its children with it; anything else is a + // plain delete. + const gone = + entity.path === "accounts" + ? ((await crm.deleteAccountCascade(req.params.id)), true) + : await crm.remove(entity.collection, req.params.id); + if (!gone) throw ApiError.notFound("Not found"); + await crmAudit(req, `crm.${entity.path}.delete`, req.params.id, before, null); + res.status(204).send(); + }), + ); +} + +/** Everything that needs the vendor's attention today, in one call. */ +vendorRouter.get( + "/crm/dashboard", + asyncHandler(async (_req, res) => { + res.json({ data: await crm.dashboard(today()) }); + }), +); diff --git a/backend/functions/src/routes/work.integration.test.ts b/backend/functions/src/routes/work.integration.test.ts new file mode 100644 index 0000000..9f4ef46 --- /dev/null +++ b/backend/functions/src/routes/work.integration.test.ts @@ -0,0 +1,464 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { db } from "../lib/firestore"; + +/** + * Work assignment end to end. + * + * The questions worth asking of a database here are the ones the pure tests + * cannot: does a team assignment reach the right people's phones, can an + * employee only touch his own work, and does one company's plan stay invisible + * to another. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); + +const token = vi.hoisted(() => ({ claims: {} as Record })); +vi.mock("firebase-admin/auth", async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + getAuth: () => ({ + verifyIdToken: async () => { + if (!token.claims.uid) throw new Error("no token"); + return token.claims; + }, + }), + }; +}); + +const { createApp } = await import("../app"); +const app = createApp(); + +async function request( + method: string, + path: string, + body?: unknown, +): Promise<{ status: number; body: Record }> { + const { createServer } = await import("node:http"); + const server = createServer(app); + await new Promise((r) => server.listen(0, r)); + const port = (server.address() as { port: number }).port; + try { + const res = await fetch(`http://127.0.0.1:${port}${path}`, { + method, + headers: { Authorization: "Bearer t", "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : {} }; + } finally { + server.close(); + } +} + +let cid = ""; +let seq = 0; + +const asLead = () => ({ uid: "u_lead", cid, eid: "e_lead", r: ["TEAM_LEAD"], email_verified: true }); +const asWorker = (eid: string) => ({ + uid: `u_${eid}`, + cid, + eid, + r: ["EMPLOYEE"], + email_verified: true, +}); + +/** A Sunday, so "next working day" is an ordinary tomorrow unless a test says otherwise. */ +const TODAY = "2026-09-06"; + +async function seedCompany(): Promise { + await db.collection("companies").doc(cid).set({ + name: "Kabul Construction", + settings: { policies: { weekendDays: [5] }, profile: { timezone: "Asia/Kabul" } }, + }); + const people: [string, string, string][] = [ + ["e_lead", "Yusuf", "Karimi"], + ["e_ali", "Ali", "Rahimi"], + ["e_omar", "Omar", "Nazari"], + ["e_spark", "Fatima", "Sadat"], + ]; + await Promise.all( + people.map(([id, firstName, lastName]) => + db.collection("companies").doc(cid).collection("employees").doc(id).set({ + firstName, + lastName, + status: "ACTIVE", + }), + ), + ); +} + +async function makeProject(): Promise { + const res = await request("POST", "/v1/work/projects", { + name: "Darulaman Tower", + code: "DT", + }); + expect(res.status).toBe(201); + return res.body.data.id as string; +} + +async function makeTeam(memberIds: string[]): Promise { + const res = await request("POST", "/v1/work/teams", { name: "Concrete crew", memberIds }); + expect(res.status).toBe(201); + return res.body.data.id as string; +} + +describe.skipIf(!EMULATOR)("work assignment", () => { + beforeEach(async () => { + seq += 1; + cid = `work_${Date.now()}_${seq}`; + await seedCompany(); + token.claims = asLead(); + }); + + it("assigning a crew puts the task on every member's day", async () => { + const projectId = await makeProject(); + const teamId = await makeTeam(["e_ali", "e_omar"]); + + const created = await request("POST", "/v1/work/tasks", { + projectId, + teamId, + title: "Pour the third-floor slab", + startDate: TODAY, + }); + expect(created.status).toBe(201); + expect(created.body.data.assigneeIds).toEqual(["e_ali", "e_omar"]); + // The phone pulls only its own employee row, so the names travel with it. + expect(created.body.data.assigneeNames).toEqual(["Ali Rahimi", "Omar Nazari"]); + expect(created.body.data.teamName).toBe("Concrete crew"); + // Omitting the end date means one day, not an open-ended task. + expect(created.body.data.endDate).toBe(TODAY); + + for (const eid of ["e_ali", "e_omar"]) { + token.claims = asWorker(eid); + const mine = await request("GET", `/v1/work/mine?date=${TODAY}`); + expect(mine.status).toBe(200); + expect(mine.body.data.today.tasks).toHaveLength(1); + expect(mine.body.data.today.tasks[0].title).toBe("Pour the third-floor slab"); + expect(mine.body.data.today.tasks[0].projectName).toBe("Darulaman Tower"); + } + + token.claims = asWorker("e_spark"); + const notMine = await request("GET", `/v1/work/mine?date=${TODAY}`); + expect(notMine.body.data.today.tasks).toHaveLength(0); + }); + + it("keeps an individual assignment individual", async () => { + const projectId = await makeProject(); + await request("POST", "/v1/work/tasks", { + projectId, + assigneeIds: ["e_spark"], + title: "Run the site power", + startDate: TODAY, + }); + + token.claims = asWorker("e_spark"); + expect((await request("GET", `/v1/work/mine?date=${TODAY}`)).body.data.today.tasks).toHaveLength(1); + token.claims = asWorker("e_ali"); + expect((await request("GET", `/v1/work/mine?date=${TODAY}`)).body.data.today.tasks).toHaveLength(0); + }); + + it("a crew plus one extra man reaches all three", async () => { + const projectId = await makeProject(); + const teamId = await makeTeam(["e_ali", "e_omar"]); + const created = await request("POST", "/v1/work/tasks", { + projectId, + teamId, + assigneeIds: ["e_spark"], + title: "Slab pour with the electrician", + startDate: TODAY, + }); + expect(created.body.data.assigneeIds).toEqual(["e_ali", "e_omar", "e_spark"]); + }); + + it("shows a multi-day task on every day it runs", async () => { + const projectId = await makeProject(); + await request("POST", "/v1/work/tasks", { + projectId, + assigneeIds: ["e_ali"], + title: "Rebar, second floor", + startDate: "2026-09-06", + endDate: "2026-09-09", + }); + + token.claims = asWorker("e_ali"); + for (const date of ["2026-09-06", "2026-09-07", "2026-09-09"]) { + const res = await request("GET", `/v1/work/mine?date=${date}`); + expect(res.body.data.today.tasks).toHaveLength(1); + } + const after = await request("GET", "/v1/work/mine?date=2026-09-10"); + expect(after.body.data.today.tasks).toHaveLength(0); + }); + + it("answers Thursday's 'what about tomorrow' with Saturday", async () => { + // The whole point of the second day being computed rather than +1: Friday + // is the Afghan weekend, and an empty Friday would read as "nothing on". + const projectId = await makeProject(); + await request("POST", "/v1/work/tasks", { + projectId, + assigneeIds: ["e_ali"], + title: "Saturday's shuttering", + startDate: "2026-09-12", // Saturday + }); + + token.claims = asWorker("e_ali"); + const res = await request("GET", "/v1/work/mine?date=2026-09-10"); // Thursday + expect(res.body.data.next.date).toBe("2026-09-12"); + expect(res.body.data.next.kind).toBe("WORKING"); + expect(res.body.data.next.tasks[0].title).toBe("Saturday's shuttering"); + }); + + it("says why today is empty when the company is closed", async () => { + token.claims = asWorker("e_ali"); + const res = await request("GET", "/v1/work/mine?date=2026-09-11"); // a Friday + expect(res.body.data.today.kind).toBe("WEEKEND"); + expect(res.body.data.today.tasks).toEqual([]); + }); + + it("lets the man doing the work report on it, and nobody else", async () => { + const projectId = await makeProject(); + const created = await request("POST", "/v1/work/tasks", { + projectId, + assigneeIds: ["e_ali"], + title: "Formwork", + startDate: TODAY, + }); + const taskId = created.body.data.id; + + token.claims = asWorker("e_omar"); + const stranger = await request("POST", `/v1/work/tasks/${taskId}/status`, { + status: "DONE", + }); + expect(stranger.status).toBe(403); + + token.claims = asWorker("e_ali"); + const mine = await request("POST", `/v1/work/tasks/${taskId}/status`, { + status: "DONE", + note: "Finished before lunch", + }); + expect(mine.status).toBe(200); + expect(mine.body.data.status).toBe("DONE"); + expect(mine.body.data.completedAt).not.toBeNull(); + expect(mine.body.data.statusNote).toBe("Finished before lunch"); + }); + + it("does not let an employee re-plan the work he was given", async () => { + const projectId = await makeProject(); + const created = await request("POST", "/v1/work/tasks", { + projectId, + assigneeIds: ["e_ali"], + title: "Formwork", + startDate: TODAY, + }); + + token.claims = asWorker("e_ali"); + // Not his to re-title, re-date, or hand to somebody else. + expect( + (await request("PATCH", `/v1/work/tasks/${created.body.data.id}`, { title: "Tea break" })) + .status, + ).toBe(403); + // Nor to browse what the rest of the company is doing. + expect((await request("GET", `/v1/work/tasks?from=${TODAY}`)).status).toBe(403); + expect((await request("GET", `/v1/work/board?date=${TODAY}`)).status).toBe(403); + expect((await request("POST", "/v1/work/projects", { name: "Mine", code: "M" })).status).toBe(403); + }); + + it("reopening a finished task clears the time it was finished", async () => { + const projectId = await makeProject(); + const created = await request("POST", "/v1/work/tasks", { + projectId, + assigneeIds: ["e_ali"], + title: "Formwork", + startDate: TODAY, + }); + const taskId = created.body.data.id; + await request("POST", `/v1/work/tasks/${taskId}/status`, { status: "DONE" }); + + const reopened = await request("POST", `/v1/work/tasks/${taskId}/status`, { + status: "IN_PROGRESS", + }); + expect(reopened.body.data.completedAt).toBeNull(); + }); + + it("groups a day by person for the planner", async () => { + const projectId = await makeProject(); + const teamId = await makeTeam(["e_ali", "e_omar"]); + await request("POST", "/v1/work/tasks", { + projectId, + teamId, + title: "Slab pour", + startDate: TODAY, + }); + await request("POST", "/v1/work/tasks", { + projectId, + assigneeIds: ["e_ali"], + title: "Second job", + startDate: TODAY, + }); + + const board = await request("GET", `/v1/work/board?date=${TODAY}`); + expect(board.status).toBe(200); + const rows = board.body.data.rows as { employeeId: string; name: string; tasks: unknown[] }[]; + expect(rows.map((r) => r.employeeId).sort()).toEqual(["e_ali", "e_omar"]); + expect(rows.find((r) => r.employeeId === "e_ali")!.tasks).toHaveLength(2); + expect(rows.find((r) => r.employeeId === "e_omar")!.name).toBe("Omar Nazari"); + // Nobody with nothing on is listed: this answers "who is on what". + expect(rows.some((r) => r.employeeId === "e_spark")).toBe(false); + }); + + it("keeps one company's plan out of another's", async () => { + const projectId = await makeProject(); + await request("POST", "/v1/work/tasks", { + projectId, + assigneeIds: ["e_ali"], + title: "Ours", + startDate: TODAY, + }); + + const otherCid = `${cid}_other`; + token.claims = { uid: "u_x", cid: otherCid, eid: "e_ali", r: ["TEAM_LEAD"], email_verified: true }; + expect((await request("GET", "/v1/work/projects")).body.data).toEqual([]); + expect((await request("GET", `/v1/work/tasks?from=${TODAY}`)).body.data).toEqual([]); + // Same employee id, different tenant: still nothing. + expect((await request("GET", `/v1/work/mine?date=${TODAY}`)).body.data.today.tasks).toEqual([]); + }); + + it("refuses a task with nobody on it", async () => { + const projectId = await makeProject(); + const res = await request("POST", "/v1/work/tasks", { + projectId, + title: "Somebody do this", + startDate: TODAY, + }); + expect(res.status).toBe(422); + }); + + it("refuses a task against a project that does not exist", async () => { + const res = await request("POST", "/v1/work/tasks", { + projectId: "no_such_project", + assigneeIds: ["e_ali"], + title: "Ghost", + startDate: TODAY, + }); + expect(res.status).toBe(404); + }); + + it("will not delete a project that still has work on it", async () => { + const projectId = await makeProject(); + await request("POST", "/v1/work/tasks", { + projectId, + assigneeIds: ["e_ali"], + title: "Live work", + startDate: TODAY, + }); + const res = await request("DELETE", `/v1/work/projects/${projectId}`); + expect(res.status).toBe(422); + expect(res.body.code).toBe("CONFLICT"); + }); + + it("disbanding a crew leaves tomorrow's work assigned", async () => { + // The task was expanded to people when it was written. Deleting the team it + // came from must not quietly unassign work somebody is expecting to do. + const projectId = await makeProject(); + const teamId = await makeTeam(["e_ali", "e_omar"]); + await request("POST", "/v1/work/tasks", { + projectId, + teamId, + title: "Slab pour", + startDate: TODAY, + }); + expect((await request("DELETE", `/v1/work/teams/${teamId}`)).status).toBe(204); + + token.claims = asWorker("e_ali"); + expect((await request("GET", `/v1/work/mine?date=${TODAY}`)).body.data.today.tasks).toHaveLength(1); + }); + + it("renaming a task does not re-expand a crew that has since changed", async () => { + const projectId = await makeProject(); + const teamId = await makeTeam(["e_ali", "e_omar"]); + const created = await request("POST", "/v1/work/tasks", { + projectId, + teamId, + title: "Slab pour", + startDate: TODAY, + }); + // Omar moves off the crew tomorrow. Yesterday's work stays his. + await request("PUT", `/v1/work/teams/${teamId}`, { + name: "Concrete crew", + memberIds: ["e_ali"], + }); + const renamed = await request("PATCH", `/v1/work/tasks/${created.body.data.id}`, { + title: "Slab pour, third floor", + }); + expect(renamed.body.data.assigneeIds).toEqual(["e_ali", "e_omar"]); + + // Re-assigning on purpose does pick up the new crew. + const reassigned = await request("PATCH", `/v1/work/tasks/${created.body.data.id}`, { + teamId, + }); + expect(reassigned.body.data.assigneeIds).toEqual(["e_ali"]); + }); + + it("delivers a task to the phone through the sync pull it is scoped for", async () => { + const projectId = await makeProject(); + await request("POST", "/v1/work/tasks", { + projectId, + assigneeIds: ["e_ali"], + title: "Rebar", + startDate: TODAY, + }); + + token.claims = asWorker("e_ali"); + const mine = await request("GET", "/v1/sync/pull?type=tasks"); + expect(mine.body.data.items).toHaveLength(1); + expect(mine.body.data.items[0].title).toBe("Rebar"); + // Projects are reference data, so the phone can name what it is working on. + expect((await request("GET", "/v1/sync/pull?type=projects")).body.data.items).toHaveLength(1); + + token.claims = asWorker("e_spark"); + expect((await request("GET", "/v1/sync/pull?type=tasks")).body.data.items).toEqual([]); + }); + + it("reaches an employee in a browser even when the licence enforces devices", async () => { + // The case this used to break: a portal request carries no X-Device-Id, + // and the guard used to answer every one of them with "this device is not + // activated, sign in again" — at exactly the companies paying for + // enforcement. The licence counts phones running the app; a browser is not + // one of them. + const { setLicense } = await import("../services/license"); + const { clearDeviceGuardCache } = await import("../middleware/deviceGuard"); + await setLicense(cid, { + plan: "STANDARD", + deviceLimit: 5, + status: "ACTIVE", + expiresAt: null, + enforceDevices: true, + } as Parameters[1]); + clearDeviceGuardCache(); + + const projectId = await makeProject(); + await request("POST", "/v1/work/tasks", { + projectId, + assigneeIds: ["e_ali"], + title: "Visible from a browser", + startDate: TODAY, + }); + + token.claims = asWorker("e_ali"); + const res = await request("GET", `/v1/work/mine?date=${TODAY}`); + expect(res.status).toBe(200); + expect(res.body.data.today.tasks[0].title).toBe("Visible from a browser"); + + // And it took no seat doing so. + const devices = await db.collection("companies").doc(cid).collection("devices").get(); + expect(devices.size).toBe(0); + }); + + it("is closed to anyone without a token", async () => { + token.claims = {}; + expect((await request("GET", "/v1/work/mine")).status).toBe(401); + expect((await request("GET", "/v1/work/tasks")).status).toBe(401); + }); +}); diff --git a/backend/functions/src/routes/work.ts b/backend/functions/src/routes/work.ts new file mode 100644 index 0000000..4cba15f --- /dev/null +++ b/backend/functions/src/routes/work.ts @@ -0,0 +1,258 @@ +import { Router } from "express"; +import { ApiError, asyncHandler } from "../lib/errors"; +import { authOf } from "../middleware/auth"; +import { hasPermission, requirePermission } from "../middleware/rbac"; +import { parseBody } from "../middleware/validate"; +import { localDateOf } from "../services/attendance"; +import { getSettings } from "../services/settings"; +import { + createProject, + createTask, + createTeam, + dayBoard, + deleteProject, + deleteTask, + deleteTeam, + listProjects, + listTasks, + listTeams, + myWork, + projectWriteSchema, + setTaskStatus, + taskCreateSchema, + taskStatusSchema, + taskUpdateSchema, + teamWriteSchema, + updateProject, + updateTask, + updateTeam, +} from "../services/work"; + +/** + * Work assignment: projects, crews, and who is on what on a given day. + * + * Two audiences share this router and they are not the same shape. A planner + * asks about a date range and gets tasks; an employee asks nothing and gets + * today and his next working day, already worked out. The employee route is the + * one the phone calls, and it is the reason this feature exists. + */ +export const workRouter = Router(); + +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; + +function requireIsoDate(value: unknown, field: string): string { + const s = String(value ?? ""); + if (!ISO_DATE.test(s)) { + throw ApiError.validation(`${field} must be an ISO date (YYYY-MM-DD)`); + } + return s; +} + +/** Today where the company is, not where the server is. */ +async function todayFor(cid: string): Promise { + const settings = await getSettings(cid); + return localDateOf(new Date(), settings.profile.timezone); +} + +// ------------------------------------------------------------------ projects + +workRouter.get( + "/projects", + requirePermission("work:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + res.json({ data: await listProjects(auth.companyId) }); + }), +); + +workRouter.post( + "/projects", + requirePermission("work:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, projectWriteSchema); + const dto = await createProject(auth.companyId, payload, auth.employeeId, auth.roles); + res.status(201).json({ data: dto }); + }), +); + +workRouter.put( + "/projects/:id", + requirePermission("work:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, projectWriteSchema); + const dto = await updateProject( + auth.companyId, + req.params.id, + payload, + auth.employeeId, + auth.roles, + ); + res.json({ data: dto }); + }), +); + +workRouter.delete( + "/projects/:id", + requirePermission("work:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + await deleteProject(auth.companyId, req.params.id, auth.employeeId, auth.roles); + res.status(204).send(); + }), +); + +// --------------------------------------------------------------------- teams + +workRouter.get( + "/teams", + requirePermission("work:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + res.json({ data: await listTeams(auth.companyId) }); + }), +); + +workRouter.post( + "/teams", + requirePermission("work:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, teamWriteSchema); + const dto = await createTeam(auth.companyId, payload, auth.employeeId, auth.roles); + res.status(201).json({ data: dto }); + }), +); + +workRouter.put( + "/teams/:id", + requirePermission("work:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, teamWriteSchema); + const dto = await updateTeam(auth.companyId, req.params.id, payload, auth.employeeId, auth.roles); + res.json({ data: dto }); + }), +); + +workRouter.delete( + "/teams/:id", + requirePermission("work:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + await deleteTeam(auth.companyId, req.params.id, auth.employeeId, auth.roles); + res.status(204).send(); + }), +); + +// -------------------------------------------------------------- the employee + +/** + * "What am I on today, and what am I on next?" + * + * No parameters: the server knows who is asking and what day it is where the + * company is. A phone that guessed the date from its own clock would show the + * wrong day to anybody whose device is set to another timezone. + */ +workRouter.get( + "/mine", + requirePermission("self:tasks"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const today = req.query.date + ? requireIsoDate(req.query.date, "date") + : await todayFor(auth.companyId); + res.json({ data: await myWork(auth.companyId, auth.employeeId, today) }); + }), +); + +/** + * Report progress on your own work. The assignee may move the status; the + * service refuses anybody else who lacks work:write. + */ +workRouter.post( + "/tasks/:id/status", + requirePermission("self:tasks"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, taskStatusSchema); + const dto = await setTaskStatus( + auth.companyId, + req.params.id, + payload.status, + payload.note ?? null, + auth.employeeId, + auth.roles, + hasPermission(auth.roles, "work:write"), + ); + res.json({ data: dto }); + }), +); + +// --------------------------------------------------------------- the planner + +workRouter.get( + "/tasks", + requirePermission("work:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const from = req.query.from + ? requireIsoDate(req.query.from, "from") + : await todayFor(auth.companyId); + const to = req.query.to ? requireIsoDate(req.query.to, "to") : from; + if (to < from) throw ApiError.validation("to must not be before from"); + + res.json({ + data: await listTasks(auth.companyId, from, to, { + projectId: req.query.projectId ? String(req.query.projectId) : undefined, + employeeId: req.query.employeeId ? String(req.query.employeeId) : undefined, + }), + }); + }), +); + +/** One day, grouped by person — what the portal shows a manager each morning. */ +workRouter.get( + "/board", + requirePermission("work:read"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const date = req.query.date + ? requireIsoDate(req.query.date, "date") + : await todayFor(auth.companyId); + res.json({ data: await dayBoard(auth.companyId, date) }); + }), +); + +workRouter.post( + "/tasks", + requirePermission("work:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, taskCreateSchema); + const dto = await createTask(auth.companyId, payload, auth.employeeId, auth.roles); + res.status(201).json({ data: dto }); + }), +); + +workRouter.patch( + "/tasks/:id", + requirePermission("work:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + const payload = parseBody(req, taskUpdateSchema); + const dto = await updateTask(auth.companyId, req.params.id, payload, auth.employeeId, auth.roles); + res.json({ data: dto }); + }), +); + +workRouter.delete( + "/tasks/:id", + requirePermission("work:write"), + asyncHandler(async (req, res) => { + const auth = authOf(req); + await deleteTask(auth.companyId, req.params.id, auth.employeeId, auth.roles); + res.status(204).send(); + }), +); diff --git a/backend/functions/src/scripts/backfill-attendance.ts b/backend/functions/src/scripts/backfill-attendance.ts new file mode 100644 index 0000000..440f6d6 --- /dev/null +++ b/backend/functions/src/scripts/backfill-attendance.ts @@ -0,0 +1,268 @@ +/* + * Rebuilds attendanceDays projections from the raw punch stream. + * + * Why this exists: recomputeAttendanceDay needs a composite index on + * (employeeId ASC, punchedAt ASC) that was missing, so for every punch the + * projection write failed after the punch itself had been stored. Punches + * accumulated while the attendance board stayed empty. The index is in place + * now and each new punch rebuilds its own day, but historical days were never + * computed and nothing will touch them again on its own. + * + * The rebuild calls the same recomputeAttendanceDay the API uses, so a + * backfilled day is identical to one written by a live punch — there is no + * second implementation of the attendance maths to drift. + * + * Safe to re-run: recomputing a day derives it entirely from the punches, so + * running twice produces the same document. + * + * Usage (from backend/functions, after `npm run build`): + * + * # See what would change — writes nothing. Always start here. + * GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/backfill-attendance.js + * + * # Apply it + * GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/backfill-attendance.js --apply + * + * Credentials come from Application Default Credentials; run + * `gcloud auth application-default login` first, or point + * GOOGLE_APPLICATION_CREDENTIALS at a service-account key. + * + * Options: + * --apply Write. Without it the script only reports. + * --company= Restrict to one tenant (default: every company). + * --from=YYYY-MM-DD Only days on or after this company-local date. + * --to=YYYY-MM-DD Only days on or before this company-local date. + * --all Also recompute days that already exist. The default + * touches only missing ones, which is the damage here. + * --concurrency= Parallel day rebuilds (default 5). + */ + +import { FieldPath, Timestamp } from "firebase-admin/firestore"; +import { db, tenant } from "../lib/firestore"; +import { localDateOf, recomputeAttendanceDay } from "../services/attendance"; +import { getSettings } from "../services/settings"; + +interface Options { + apply: boolean; + company: string | null; + from: string | null; + to: string | null; + all: boolean; + concurrency: number; +} + +function parseArgs(argv: string[]): Options { + const value = (name: string): string | null => { + const hit = argv.find((a) => a.startsWith(`--${name}=`)); + return hit ? hit.slice(name.length + 3) : null; + }; + const isoOrNull = (name: string): string | null => { + const v = value(name); + if (v !== null && !/^\d{4}-\d{2}-\d{2}$/.test(v)) { + throw new Error(`--${name} must be YYYY-MM-DD, got "${v}"`); + } + return v; + }; + const concurrency = Number.parseInt(value("concurrency") ?? "5", 10); + if (!Number.isFinite(concurrency) || concurrency < 1 || concurrency > 50) { + throw new Error("--concurrency must be between 1 and 50"); + } + return { + apply: argv.includes("--apply"), + company: value("company"), + from: isoOrNull("from"), + to: isoOrNull("to"), + all: argv.includes("--all"), + concurrency, + }; +} + +/** One (employee, company-local date) pair that has at least one punch. */ +interface DayKey { + employeeId: string; + date: string; +} + +/** + * Every day that has punches, read straight from the punch stream. + * + * Paginated by document id: punch ids are ULIDs, so they are unique and + * time-ordered, and ordering by them needs no composite index. Only the two + * fields the grouping needs are fetched. + */ +async function daysWithPunches(cid: string, timezone: string): Promise { + const seen = new Set(); + const out: DayKey[] = []; + const PAGE = 500; + let cursor: string | null = null; + let scanned = 0; + + for (;;) { + let query = tenant(cid, "punches") + .select("employeeId", "punchedAt") + .orderBy(FieldPath.documentId()) + .limit(PAGE); + if (cursor) { + query = query.startAfter(cursor); + } + const snap = await query.get(); + if (snap.empty) { + break; + } + for (const doc of snap.docs) { + const data = doc.data() as { employeeId?: string; punchedAt?: Timestamp }; + scanned++; + if (!data.employeeId || !data.punchedAt) { + console.warn(` ! skipping malformed punch ${doc.id}`); + continue; + } + const date = localDateOf(data.punchedAt.toDate(), timezone); + const key = `${data.employeeId}|${date}`; + if (!seen.has(key)) { + seen.add(key); + out.push({ employeeId: data.employeeId, date }); + } + } + cursor = snap.docs[snap.docs.length - 1].id; + if (snap.size < PAGE) { + break; + } + } + + console.log(` scanned ${scanned} punches → ${out.length} distinct days`); + return out; +} + +/** Which of [days] have no attendanceDays document yet. */ +async function missingOnly(cid: string, days: DayKey[]): Promise { + const missing: DayKey[] = []; + const CHUNK = 200; // getAll takes a bounded number of refs at a time + for (let i = 0; i < days.length; i += CHUNK) { + const slice = days.slice(i, i + CHUNK); + const refs = slice.map((d) => + tenant(cid, "attendanceDays").doc(`${d.employeeId}_${d.date}`), + ); + const snaps = await db.getAll(...refs); + snaps.forEach((snap, idx) => { + if (!snap.exists) { + missing.push(slice[idx]); + } + }); + } + return missing; +} + +/** Runs [work] over [items] with at most [limit] in flight. */ +async function pool( + items: T[], + limit: number, + work: (item: T) => Promise, +): Promise { + let next = 0; + const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { + for (;;) { + const index = next++; + if (index >= items.length) { + return; + } + await work(items[index]); + } + }); + await Promise.all(runners); +} + +async function backfillCompany(cid: string, options: Options): Promise { + const settings = await getSettings(cid); + const timezone = settings.profile.timezone; + console.log(`\n=== ${cid} (timezone ${timezone})`); + + let days = await daysWithPunches(cid, timezone); + + if (options.from) { + days = days.filter((d) => d.date >= options.from!); + } + if (options.to) { + days = days.filter((d) => d.date <= options.to!); + } + if (options.from || options.to) { + console.log(` ${days.length} within the requested date range`); + } + + const targets = options.all ? days : await missingOnly(cid, days); + if (!options.all) { + console.log(` ${targets.length} missing a projection`); + } + if (targets.length === 0) { + console.log(" nothing to do"); + return 0; + } + + // Oldest first, so a partial run leaves a contiguous repaired history. + targets.sort((a, b) => a.date.localeCompare(b.date)); + + if (!options.apply) { + for (const d of targets.slice(0, 10)) { + console.log(` would rebuild ${d.date} ${d.employeeId}`); + } + if (targets.length > 10) { + console.log(` … and ${targets.length - 10} more`); + } + return 0; + } + + let done = 0; + let failed = 0; + await pool(targets, options.concurrency, async (d) => { + try { + await recomputeAttendanceDay(cid, d.employeeId, d.date, timezone); + done++; + if (done % 50 === 0) { + console.log(` rebuilt ${done}/${targets.length}`); + } + } catch (e) { + failed++; + console.error(` ✗ ${d.date} ${d.employeeId}: ${(e as Error).message}`); + } + }); + console.log(` rebuilt ${done}, failed ${failed}`); + return failed; +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + const project = + process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCLOUD_PROJECT ?? "(default)"; + + console.log(`Attendance backfill — project ${project}`); + console.log(options.apply ? "MODE: APPLY (writes)" : "MODE: dry run (no writes)"); + if (options.all) { + console.log("Recomputing existing days as well as missing ones."); + } + + const companyIds = options.company + ? [options.company] + : (await db.collection("companies").select().get()).docs.map((d) => d.id); + + if (companyIds.length === 0) { + console.log("No companies found — check the project and credentials."); + return; + } + + let failed = 0; + for (const cid of companyIds) { + failed += await backfillCompany(cid, options); + } + + if (!options.apply) { + console.log("\nDry run only. Re-run with --apply to write."); + } + if (failed > 0) { + console.error(`\n${failed} day(s) failed to rebuild.`); + process.exitCode = 1; + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/backend/functions/src/scripts/grant-vendor.ts b/backend/functions/src/scripts/grant-vendor.ts new file mode 100644 index 0000000..392e247 --- /dev/null +++ b/backend/functions/src/scripts/grant-vendor.ts @@ -0,0 +1,159 @@ +/* + * Grants (or revokes) vendor access — Linumic staff, not any customer. + * + * A vendor account can read every company and write any licence, so the claim + * that marks one must be unobtainable through the product. It is: no signup, + * invite or employee route writes custom claims at all, and this script needs + * credentials for the Firebase project itself. + * + * The account must already exist. Create it in the Firebase console + * (Authentication → Users → Add user) — Claude does not create accounts or + * handle passwords — then run this against it. The console leaves the address + * unverified and offers no way to change that, so this script marks it. + * + * Usage (from backend/functions, after `npm run build`): + * + * # Always start here: prints what would change, writes nothing. + * GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/grant-vendor.js \ + * --email you@linumic.com + * + * # Grant + * ... --email you@linumic.com --apply + * + * # Take it away + * ... --email someone@linumic.com --revoke --apply + * + * # Who has it + * ... --list + * + * Credentials come from Application Default Credentials; run + * `gcloud auth application-default login` first. + * + * After a grant the person must sign out and back in: custom claims reach the + * client in a fresh ID token, not the one already in their browser. + */ + +import { getAuth } from "firebase-admin/auth"; +import { initializeApp, applicationDefault, getApps } from "firebase-admin/app"; + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +function flag(name: string): boolean { + return process.argv.includes(`--${name}`); +} + +function fail(message: string): never { + console.error(`\n ✗ ${message}\n`); + process.exit(1); +} + +async function main(): Promise { + const projectId = + process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || ""; + if (!projectId) { + fail("Set GOOGLE_CLOUD_PROJECT to the Firebase project, e.g. worktrack-prod"); + } + if (!getApps().length) { + initializeApp({ credential: applicationDefault(), projectId }); + } + const auth = getAuth(); + + console.log(`\n project: ${projectId}\n`); + + if (flag("list")) { + // Small staff list; one page is plenty and paging every user of the project + // to find them would be wasteful. + const page = await auth.listUsers(1000); + const staff = page.users.filter((u) => u.customClaims?.vendor === true); + if (!staff.length) { + console.log(" Nobody has vendor access.\n"); + return; + } + for (const u of staff) { + console.log(` ${u.email ?? u.uid}`); + console.log(` uid ${u.uid} email verified: ${u.emailVerified ? "yes" : "NO"}\n`); + } + return; + } + + const email = arg("email"); + if (!email) fail("--email is required (or use --list)"); + + const user = await auth.getUserByEmail(email).catch(() => null); + if (!user) { + fail( + `No account for ${email} in ${projectId}.\n` + + " Create it in the Firebase console first — Authentication → Users → Add user.", + ); + } + + const claims = user.customClaims ?? {}; + const revoking = flag("revoke"); + + // A vendor identity must carry no tenant claims. One that did could act on a + // company through the ordinary routes while also holding cross-tenant + // authority — and middleware/vendor.ts refuses such a token anyway, so + // granting it here would produce an account that simply does not work. + if (!revoking && (claims.cid || claims.eid)) { + fail( + `${email} is an employee of company ${claims.cid}.\n` + + " A vendor account must not belong to any customer. Use a separate\n" + + " address for staff access.", + ); + } + + // Adding a user in the Firebase console leaves emailVerified false and gives + // no way to change it, so requiring the address be verified beforehand asked + // for something that cannot be done. Mark it here instead. + // + // That is legitimate for this account and only this account: it is created by + // whoever owns the project, using credentials only they have, for an address + // they chose. There is no stranger's self-asserted address to guard against — + // which is what the check in middleware/vendor.ts exists for, and why that + // check stays. + const needsVerifying = !revoking && !user.emailVerified; + + const has = claims.vendor === true; + console.log(` account: ${email}`); + console.log(` now: vendor access ${has ? "GRANTED" : "not granted"}`); + console.log(` next: vendor access ${revoking ? "not granted" : "GRANTED"}`); + + if (needsVerifying) { + console.log(" also: mark the address verified"); + } + + if (has === !revoking && !needsVerifying) { + console.log("\n Nothing would change.\n"); + return; + } + + if (!flag("apply")) { + console.log("\n Dry run — nothing written. Re-run with --apply.\n"); + return; + } + + const next = { ...claims }; + if (revoking) delete next.vendor; + else next.vendor = true; + await auth.setCustomUserClaims(user.uid, next); + if (needsVerifying) { + await auth.updateUser(user.uid, { emailVerified: true }); + console.log("\n ✓ Address marked verified (the console cannot do this)."); + } + + // Existing ID tokens keep working for up to an hour; revoking must bite now. + if (revoking) { + await auth.revokeRefreshTokens(user.uid); + console.log("\n ✓ Vendor access removed, and existing sessions revoked.\n"); + } else { + console.log("\n ✓ Vendor access granted. Sign out and back in to pick it up.\n"); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/backend/functions/src/scripts/set-license.ts b/backend/functions/src/scripts/set-license.ts new file mode 100644 index 0000000..52666ad --- /dev/null +++ b/backend/functions/src/scripts/set-license.ts @@ -0,0 +1,226 @@ +/* + * Issues a licence to a company. This is the vendor's tool — the thing that + * turns a paid invoice into a working, limited installation. + * + * Why it is a script and not an endpoint: the licence is what the customer + * buys, so it must not be something they can grant themselves. There is + * deliberately no PUT /v1/devices/license (see routes/devices.ts) — inside a + * tenant, COMPANY_ADMIN holds "*", so any such endpoint would have let a + * customer set their own seat count and clear their own expiry. Writing a + * licence requires credentials for the Firebase project itself, which only the + * vendor has. + * + * Usage (from backend/functions, after `npm run build`): + * + * # Always start here — prints what would change and writes nothing. + * GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js \ + * --company COMPANY_ID --plan STANDARD --seats 25 --expires 2027-03-20 + * + * # Apply it + * ... --apply + * + * # Read back what a company holds today + * GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js \ + * --company COMPANY_ID --show + * + * Credentials come from Application Default Credentials; run + * `gcloud auth application-default login` first, or point + * GOOGLE_APPLICATION_CREDENTIALS at a service-account key. Claude never + * handles that key. + * + * Options: + * --company Required. The company id (visible in the portal's + * Devices page, and printed by --list). + * --plan

FREE | STANDARD | ENTERPRISE. Default: keep current. + * --seats Device seats granted. Default: keep current. + * --expires YYYY-MM-DD (Gregorian), or "never". Default: keep. + * --status ACTIVE | SUSPENDED | EXPIRED. Default: ACTIVE. + * --enforce / --no-enforce + * Whether the seat limit actually refuses devices. + * Default on a new licence: --enforce. + * --list List every company with its licence, then exit. + * --show Print this company's licence, then exit. + * --apply Actually write. Without it, nothing is written. + */ + +import { getFirestore } from "firebase-admin/firestore"; +import { initializeApp, applicationDefault, getApps } from "firebase-admin/app"; + +interface License { + plan: string; + deviceLimit: number; + status: string; + expiresAt: string | null; + enforceDevices: boolean; +} + +const DEFAULTS: License = { + plan: "FREE", + deviceLimit: 5, + status: "ACTIVE", + expiresAt: null, + enforceDevices: false, +}; + +const PLANS = ["FREE", "STANDARD", "ENTERPRISE"]; +const STATUSES = ["ACTIVE", "SUSPENDED", "EXPIRED"]; + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +function flag(name: string): boolean { + return process.argv.includes(`--${name}`); +} + +function fail(message: string): never { + console.error(`\n ✗ ${message}\n`); + process.exit(1); +} + +function describe(l: License): string { + return [ + `plan=${l.plan}`, + `seats=${l.deviceLimit}`, + `status=${l.status}`, + `expires=${l.expiresAt ?? "never"}`, + `enforced=${l.enforceDevices ? "yes" : "no"}`, + ].join(" "); +} + +async function main(): Promise { + const projectId = + process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || ""; + if (!projectId) { + fail("Set GOOGLE_CLOUD_PROJECT to the Firebase project, e.g. worktrack-prod"); + } + + if (!getApps().length) { + initializeApp({ credential: applicationDefault(), projectId }); + } + const db = getFirestore(); + + console.log(`\n project: ${projectId}\n`); + + if (flag("list")) { + const snap = await db.collection("companies").get(); + if (snap.empty) { + console.log(" (no companies)\n"); + return; + } + for (const doc of snap.docs) { + const d = doc.data(); + const l: License = { ...DEFAULTS, ...(d.license ?? {}) }; + const name = (d.name as string) ?? "(unnamed)"; + console.log(` ${doc.id}`); + console.log(` ${name}`); + console.log(` ${describe(l)}\n`); + } + return; + } + + const cid = arg("company"); + if (!cid) fail("--company is required (or use --list)"); + + const ref = db.collection("companies").doc(cid); + const snap = await ref.get(); + if (!snap.exists) fail(`No company "${cid}" in ${projectId}`); + + const data = snap.data() ?? {}; + const current: License = { ...DEFAULTS, ...(data.license ?? {}) }; + console.log(` company: ${cid} (${(data.name as string) ?? "unnamed"})`); + console.log(` now: ${describe(current)}`); + + if (flag("show")) { + console.log(); + return; + } + + // Anything not given keeps its current value, so a renewal is one flag. + const next: License = { ...current }; + + const plan = arg("plan"); + if (plan) { + if (!PLANS.includes(plan)) fail(`--plan must be one of ${PLANS.join(", ")}`); + next.plan = plan; + } + + const seats = arg("seats"); + if (seats) { + const n = Number(seats); + if (!Number.isInteger(n) || n < 1 || n > 100_000) { + fail("--seats must be a whole number from 1 to 100000"); + } + next.deviceLimit = n; + } + + const expires = arg("expires"); + if (expires) { + if (expires === "never") { + next.expiresAt = null; + } else if (!/^\d{4}-\d{2}-\d{2}$/.test(expires)) { + fail('--expires must be YYYY-MM-DD (Gregorian) or "never"'); + } else if (Number.isNaN(Date.parse(`${expires}T00:00:00Z`))) { + fail(`--expires "${expires}" is not a real date`); + } else { + next.expiresAt = expires; + } + } + + const status = arg("status"); + if (status) { + if (!STATUSES.includes(status)) { + fail(`--status must be one of ${STATUSES.join(", ")}`); + } + next.status = status; + } else if (!data.license) { + next.status = "ACTIVE"; + } + + if (flag("enforce") && flag("no-enforce")) { + fail("Pass either --enforce or --no-enforce, not both"); + } + if (flag("enforce")) next.enforceDevices = true; + if (flag("no-enforce")) next.enforceDevices = false; + + console.log(` next: ${describe(next)}`); + + if (JSON.stringify(next) === JSON.stringify(current)) { + console.log("\n Nothing would change.\n"); + return; + } + + // Seats are what the customer paid for; shrinking them below what is already + // in use does not un-register anyone, it just refuses the next activation. + // Say so rather than letting the vendor discover it from a support call. + if (next.deviceLimit < current.deviceLimit) { + const devices = await db.collection(`companies/${cid}/devices`).get(); + const active = devices.docs.filter((d) => { + const v = d.data(); + return (v.status ?? (v.active ? "ACTIVE" : "REVOKED")) === "ACTIVE"; + }).length; + if (active > next.deviceLimit) { + console.log( + `\n ! ${active} devices are registered but the new licence grants ${next.deviceLimit}.`, + ); + console.log( + " Registered devices keep working; the next new one is refused.", + ); + console.log(" Revoke the retired devices in the portal to tidy the count."); + } + } + + if (!flag("apply")) { + console.log("\n Dry run — nothing written. Re-run with --apply.\n"); + return; + } + + await ref.set({ license: next }, { merge: true }); + console.log("\n ✓ Licence written.\n"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/backend/functions/src/services/accounting.ts b/backend/functions/src/services/accounting.ts new file mode 100644 index 0000000..f6a9bcb --- /dev/null +++ b/backend/functions/src/services/accounting.ts @@ -0,0 +1,321 @@ +import { ApiError } from "../lib/errors"; +import { nowTimestamp, tenant } from "../lib/firestore"; +import { ulid } from "../lib/ids"; + +/** + * Lightweight double-entry accounting for the finance module. + * + * - `accounts` : the company's chart of accounts (code + type). + * - `journalEntries` : balanced debit/credit entries; the ledger of record. + * + * A trial balance is derived by summing journal lines per account. Normal + * balances follow accounting convention: ASSET/EXPENSE are debit-normal, + * LIABILITY/EQUITY/INCOME are credit-normal. + */ + +export type AccountType = "ASSET" | "LIABILITY" | "EQUITY" | "INCOME" | "EXPENSE"; + +export const ACCOUNT_TYPES: AccountType[] = [ + "ASSET", + "LIABILITY", + "EQUITY", + "INCOME", + "EXPENSE", +]; + +/** Debit-normal account types; the rest are credit-normal. */ +const DEBIT_NORMAL: ReadonlySet = new Set(["ASSET", "EXPENSE"]); + +export interface AccountDto { + id: string; + code: string; + name: string; + type: AccountType; + active: boolean; +} + +export interface JournalLine { + accountCode: string; + accountName: string; + debit: number; + credit: number; +} + +export interface JournalEntryDto { + id: string; + date: string; + memo: string; + reference: string | null; + source: "MANUAL" | "EXPENSE" | "PAYROLL"; + lines: JournalLine[]; + totalDebit: number; + createdBy: string; + createdAt: string | null; +} + +/** Starter chart of accounts seeded on first read (Afghan SME defaults, AFN). */ +export const DEFAULT_ACCOUNTS: { code: string; name: string; type: AccountType }[] = [ + { code: "1000", name: "Cash", type: "ASSET" }, + { code: "1010", name: "Bank", type: "ASSET" }, + { code: "1200", name: "Accounts Receivable", type: "ASSET" }, + { code: "2000", name: "Accounts Payable", type: "LIABILITY" }, + { code: "2100", name: "Salaries Payable", type: "LIABILITY" }, + { code: "2200", name: "Taxes Payable", type: "LIABILITY" }, + { code: "2300", name: "Employee Withholdings", type: "LIABILITY" }, + { code: "2400", name: "Employer Contributions Payable", type: "LIABILITY" }, + { code: "3000", name: "Owner's Equity", type: "EQUITY" }, + { code: "4000", name: "Service Revenue", type: "INCOME" }, + { code: "4100", name: "Other Income", type: "INCOME" }, + { code: "5000", name: "Salaries & Wages", type: "EXPENSE" }, + { code: "5100", name: "Rent", type: "EXPENSE" }, + { code: "5200", name: "Utilities", type: "EXPENSE" }, + { code: "5300", name: "Office Supplies", type: "EXPENSE" }, + { code: "5400", name: "Travel & Transport", type: "EXPENSE" }, + { code: "5900", name: "Other Expenses", type: "EXPENSE" }, +]; + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +/** Reads the chart of accounts, seeding the defaults on first access. */ +export async function listAccounts(cid: string): Promise { + const col = tenant(cid, "accounts"); + let snap = await col.get(); + if (snap.empty) { + const batch = col.firestore.batch(); + for (const a of DEFAULT_ACCOUNTS) { + batch.set(col.doc(a.code), { ...a, active: true, createdAt: nowTimestamp() }); + } + await batch.commit(); + snap = await col.get(); + } + return snap.docs + .map((d) => { + const v = d.data() as Record; + return { + id: d.id, + code: (v.code as string) ?? d.id, + name: (v.name as string) ?? "", + type: (v.type as AccountType) ?? "EXPENSE", + active: (v.active as boolean) ?? true, + }; + }) + .sort((a, b) => a.code.localeCompare(b.code)); +} + +export async function createAccount( + cid: string, + input: { code: string; name: string; type: AccountType }, +): Promise { + const col = tenant(cid, "accounts"); + const existing = await col.doc(input.code).get(); + if (existing.exists) { + throw new ApiError(409, "CONFLICT", `Account ${input.code} already exists`); + } + await col.doc(input.code).set({ ...input, active: true, createdAt: nowTimestamp() }); + return { id: input.code, ...input, active: true }; +} + +/** + * Makes sure the given default accounts exist before something posts to them. + * + * The chart is seeded only when it is empty, so a company created before an + * account code was added would never get it — and a journal line posted to a + * code that is not in the chart is dropped from the trial balance entirely + * (see computeTrialBalance), silently unbalancing the books. Call this before + * posting to any account the caller did not read from listAccounts. + */ +export async function ensureAccounts(cid: string, codes: string[]): Promise { + const col = tenant(cid, "accounts"); + const missing = ( + await Promise.all( + codes.map(async (code) => ((await col.doc(code).get()).exists ? null : code)), + ) + ).filter((code): code is string => code !== null); + if (!missing.length) return; + + const batch = col.firestore.batch(); + for (const code of missing) { + const a = DEFAULT_ACCOUNTS.find((d) => d.code === code); + if (!a) continue; + batch.set(col.doc(a.code), { ...a, active: true, createdAt: nowTimestamp() }); + } + await batch.commit(); +} + +/** + * Posts a balanced journal entry. Throws 422 if debits ≠ credits or the entry + * has fewer than two lines. Returns the created entry id. + */ +export interface JournalEntryInput { + date: string; + memo: string; + reference?: string | null; + source: JournalEntryDto["source"]; + lines: { accountCode: string; accountName: string; debit: number; credit: number }[]; + createdBy: string; + /** + * Deterministic document id. Supplying one derived from the thing being + * posted (an expense id, a payroll run id) makes the write idempotent: a + * retry overwrites the same document instead of minting a second entry that + * double-counts in the ledger. Omit it for genuinely one-off manual entries. + */ + entryId?: string; +} + +/** + * Validates an entry and returns the document to write, without touching + * Firestore. Split out from postJournalEntry so a caller can write the entry + * inside its own transaction and keep the ledger in step with the state change + * that caused it. + */ +export function buildJournalEntry( + entry: JournalEntryInput, +): { id: string; doc: Record } { + if (entry.lines.length < 2) { + throw ApiError.validation("A journal entry needs at least two lines"); + } + const totalDebit = round2(entry.lines.reduce((s, l) => s + (l.debit || 0), 0)); + const totalCredit = round2(entry.lines.reduce((s, l) => s + (l.credit || 0), 0)); + if (totalDebit !== totalCredit) { + throw ApiError.validation( + `Journal entry is unbalanced: debits ${totalDebit} ≠ credits ${totalCredit}`, + ); + } + if (totalDebit === 0) { + throw ApiError.validation("Journal entry total cannot be zero"); + } + + return { + id: entry.entryId ?? ulid(), + doc: { + date: entry.date, + memo: entry.memo, + reference: entry.reference ?? null, + source: entry.source, + lines: entry.lines.map((l) => ({ + accountCode: l.accountCode, + accountName: l.accountName, + debit: round2(l.debit || 0), + credit: round2(l.credit || 0), + })), + totalDebit, + createdBy: entry.createdBy, + createdAt: nowTimestamp(), + }, + }; +} + +export async function postJournalEntry(cid: string, entry: JournalEntryInput): Promise { + const { id, doc } = buildJournalEntry(entry); + await tenant(cid, "journalEntries").doc(id).set(doc); + return id; +} + +export async function listJournalEntries(cid: string, limit = 100): Promise { + const snap = await tenant(cid, "journalEntries").get(); + return snap.docs + .map((d) => { + const v = d.data() as Record; + const createdAt = v.createdAt as { toDate?: () => Date } | undefined; + return { + id: d.id, + date: (v.date as string) ?? "", + memo: (v.memo as string) ?? "", + reference: (v.reference as string | null) ?? null, + source: (v.source as JournalEntryDto["source"]) ?? "MANUAL", + lines: (v.lines as JournalLine[]) ?? [], + totalDebit: (v.totalDebit as number) ?? 0, + createdBy: (v.createdBy as string) ?? "", + createdAt: createdAt?.toDate ? createdAt.toDate().toISOString() : null, + }; + }) + .sort((a, b) => b.date.localeCompare(a.date)) + .slice(0, limit); +} + +export interface TrialBalanceRow { + code: string; + name: string; + type: AccountType; + debit: number; + credit: number; + balance: number; +} + +export interface TrialBalance { + rows: TrialBalanceRow[]; + totalDebit: number; + totalCredit: number; + byType: Record; + netProfit: number; +} + +/** Aggregates all journal lines into a per-account trial balance. */ +export async function computeTrialBalance(cid: string): Promise { + const [accounts, snap] = await Promise.all([ + listAccounts(cid), + tenant(cid, "journalEntries").get(), + ]); + + const totals = new Map(); + for (const doc of snap.docs) { + const lines = (doc.data().lines as JournalLine[] | undefined) ?? []; + for (const l of lines) { + const cur = totals.get(l.accountCode) ?? { debit: 0, credit: 0 }; + cur.debit += l.debit || 0; + cur.credit += l.credit || 0; + totals.set(l.accountCode, cur); + } + } + + const byType: Record = { + ASSET: 0, + LIABILITY: 0, + EQUITY: 0, + INCOME: 0, + EXPENSE: 0, + }; + let totalDebit = 0; + let totalCredit = 0; + + // A line posted to a code that is not in the chart would otherwise vanish + // here, leaving totalDebit ≠ totalCredit with nothing to show why. Surface + // those codes as their own rows instead of dropping them. + const charted = new Set(accounts.map((a) => a.code)); + const orphans: AccountDto[] = [...totals.keys()] + .filter((code) => !charted.has(code)) + .sort() + .map((code) => ({ + id: code, + code, + name: `Unknown account ${code}`, + type: "EXPENSE" as AccountType, + active: false, + })); + + const rows: TrialBalanceRow[] = [...accounts, ...orphans] + .map((a) => { + const t = totals.get(a.code) ?? { debit: 0, credit: 0 }; + const debit = round2(t.debit); + const credit = round2(t.credit); + // Signed balance in the account's normal direction. + const balance = DEBIT_NORMAL.has(a.type) + ? round2(debit - credit) + : round2(credit - debit); + byType[a.type] = round2(byType[a.type] + balance); + totalDebit = round2(totalDebit + debit); + totalCredit = round2(totalCredit + credit); + return { code: a.code, name: a.name, type: a.type, debit, credit, balance }; + }) + .filter((r) => r.debit !== 0 || r.credit !== 0); + + return { + rows, + totalDebit, + totalCredit, + byType, + netProfit: round2(byType.INCOME - byType.EXPENSE), + }; +} diff --git a/backend/functions/src/services/advanceStore.ts b/backend/functions/src/services/advanceStore.ts new file mode 100644 index 0000000..0603f61 --- /dev/null +++ b/backend/functions/src/services/advanceStore.ts @@ -0,0 +1,228 @@ +import { ApiError, ErrorCodes } from "../lib/errors"; +import { nowTimestamp, tenant } from "../lib/firestore"; +import { ulid } from "../lib/ids"; +import { balanceAfter, type OutstandingAdvance, type Repayment } from "./advances"; + +/** + * Storing advances, and taking them back a payroll run at a time. + * + * The shape is driven by one fact about payroll: a run can be recomputed. + * Payslip ids are derived from the run and the journal entry overwrites in + * place, so re-running a month is a supported, ordinary thing to do — and an + * advance repayment that simply subtracted from a balance would take the money + * twice for one month's pay. + * + * So a repayment is not an event that mutates a balance. It is a record keyed + * by the run that caused it, and the balance is derived: + * + * outstanding = principal − sum(repayments) + * + * Recomputing a month rewrites that month's repayment in place and everything + * else follows. Asking for the balance while computing run X deliberately + * ignores X's own repayment, so the run sees the month as it was before it + * touched anything, however many times it is run. + */ + +export interface AdvanceDoc { + employeeId: string; + employeeName: string; + principal: number; + instalment: number | null; + issuedOn: string; + note: string | null; + /** Derived; kept on the document so a list does not have to sum subcollections. */ + repaid: number; + status: "OUTSTANDING" | "SETTLED" | "CANCELLED"; + createdBy: string; + createdAt: FirebaseFirestore.Timestamp; + updatedAt: FirebaseFirestore.Timestamp; +} + +export function advanceToDto(id: string, doc: AdvanceDoc): Record { + return { + id, + employeeId: doc.employeeId, + employeeName: doc.employeeName, + principal: doc.principal, + instalment: doc.instalment, + issuedOn: doc.issuedOn, + note: doc.note, + repaid: doc.repaid, + outstanding: balanceAfter(doc.principal, doc.repaid), + status: doc.status, + }; +} + +export async function createAdvance( + cid: string, + input: { + employeeId: string; + principal: number; + instalment: number | null; + issuedOn: string; + note: string | null; + }, + createdBy: string, +): Promise<{ id: string; doc: AdvanceDoc }> { + const employee = await tenant(cid, "employees").doc(input.employeeId).get(); + if (!employee.exists) { + throw ApiError.notFound("Employee not found"); + } + const e = employee.data() as { firstName?: string; lastName?: string }; + + const now = nowTimestamp(); + const doc: AdvanceDoc = { + employeeId: input.employeeId, + employeeName: `${e.firstName ?? ""} ${e.lastName ?? ""}`.trim(), + principal: input.principal, + instalment: input.instalment, + issuedOn: input.issuedOn, + note: input.note, + repaid: 0, + status: "OUTSTANDING", + createdBy, + createdAt: now, + updatedAt: now, + }; + const id = ulid(); + await tenant(cid, "advances").doc(id).create(doc); + return { id, doc }; +} + +/** + * Cancels an advance that should not have been recorded. + * + * Cancelling, not deleting: money that was handed to somebody and then written + * off is exactly the transaction an audit needs to still be able to see. An + * advance that has already had something repaid cannot be cancelled at all, + * because the repayment happened and the payslip that made it is issued. + */ +export async function cancelAdvance(cid: string, id: string): Promise { + const ref = tenant(cid, "advances").doc(id); + const snap = await ref.get(); + if (!snap.exists) throw ApiError.notFound("Advance not found"); + const doc = snap.data() as AdvanceDoc; + + if (doc.repaid > 0) { + throw ApiError.business( + ErrorCodes.INVALID_STATE, + "Some of this advance has already been repaid; it cannot be cancelled", + ); + } + await ref.update({ status: "CANCELLED", updatedAt: nowTimestamp() }); +} + +/** + * What each of these employees still owes, as the given run should see it. + * + * `excludeRunId` is what makes a recomputation safe: the balances come back as + * they were before this run last touched them, so running the same month twice + * takes the same money once. + */ +export async function outstandingFor( + cid: string, + employeeIds: readonly string[], + excludeRunId: string | null, +): Promise> { + const byEmployee = new Map(); + if (employeeIds.length === 0) return byEmployee; + + // SETTLED ones are included on purpose. A previous attempt at THIS run may + // be what settled them, and excluding them here would make a recomputation + // silently forgive the debt: the run would see nothing owing, deduct + // nothing, and the reconciliation below would then delete the repayment that + // had paid it off. Only CANCELLED is genuinely out of scope. What is + // actually settled falls out below, once the balance is adjusted for this + // run's own repayment. + const snap = await tenant(cid, "advances") + .where("status", "in", ["OUTSTANDING", "SETTLED"]) + .get(); + const wanted = new Set(employeeIds); + + await Promise.all( + snap.docs.map(async (advanceDoc) => { + const doc = advanceDoc.data() as AdvanceDoc; + if (!wanted.has(doc.employeeId)) return; + + let repaid = doc.repaid; + if (excludeRunId) { + const mine = await advanceDoc.ref.collection("repayments").doc(excludeRunId).get(); + if (mine.exists) { + repaid = balanceAfter(repaid, (mine.data() as { amount: number }).amount); + } + } + + const balance = balanceAfter(doc.principal, repaid); + if (balance <= 0) return; + + const list = byEmployee.get(doc.employeeId) ?? []; + list.push({ + id: advanceDoc.id, + balance, + instalment: doc.instalment, + issuedOn: doc.issuedOn, + }); + byEmployee.set(doc.employeeId, list); + }), + ); + + return byEmployee; +} + +/** + * Brings this run's repayment records in line with what it just decided. + * + * Reconciles rather than appends, over every advance the run CONSIDERED — not + * only the ones it took money from. A recomputation can legitimately repay + * less than last time, or nothing at all, because attendance was corrected and + * the pay no longer covers it. Writing only the new repayments would leave the + * previous, larger one in place and the employee would still be shown as + * having paid it. + * + * The record id is the run id, so the second run of a month replaces the first + * run's record instead of adding to it. `repaid` is then recomputed from the + * records that remain rather than incremented — an increment applied twice is + * the exact bug this whole design exists to prevent. + */ +export async function reconcileRepayments( + cid: string, + runId: string, + periodLabel: string, + consideredAdvanceIds: readonly string[], + repayments: readonly Repayment[], +): Promise { + const byAdvance = new Map(repayments.map((r) => [r.advanceId, r])); + + await Promise.all( + consideredAdvanceIds.map(async (advanceId) => { + const ref = tenant(cid, "advances").doc(advanceId); + const recordRef = ref.collection("repayments").doc(runId); + const repayment = byAdvance.get(advanceId); + + if (repayment) { + await recordRef.set({ + runId, + period: periodLabel, + amount: repayment.amount, + at: nowTimestamp(), + }); + } else { + await recordRef.delete().catch(() => undefined); + } + + const all = await ref.collection("repayments").get(); + const repaid = + Math.round( + all.docs.reduce((sum, d) => sum + ((d.data() as { amount?: number }).amount ?? 0), 0) * 100, + ) / 100; + + const snap = await ref.get(); + const principal = (snap.data() as AdvanceDoc | undefined)?.principal ?? 0; + await ref.update({ + repaid, + status: balanceAfter(principal, repaid) <= 0 ? "SETTLED" : "OUTSTANDING", + updatedAt: nowTimestamp(), + }); + }), + ); +} diff --git a/backend/functions/src/services/advances.test.ts b/backend/functions/src/services/advances.test.ts new file mode 100644 index 0000000..fe8e47e --- /dev/null +++ b/backend/functions/src/services/advances.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { planRepayments, balanceAfter, type OutstandingAdvance } from "./advances"; + +function advance(over: Partial = {}): OutstandingAdvance { + return { id: "a1", balance: 5000, instalment: null, issuedOn: "2026-09-01", ...over }; +} + +describe("taking an advance back out of a payslip", () => { + it("takes the whole thing when no instalment was set", () => { + // A small advance a few days before payday: the default is to settle it at + // the next payroll rather than dribble it out. + const plan = planRepayments([advance({ balance: 5000 })], 20000); + + expect(plan.total).toBe(5000); + expect(plan.repayments[0].remaining).toBe(0); + expect(plan.shortfall).toBe(false); + }); + + it("takes the instalment when one was agreed", () => { + const plan = planRepayments([advance({ balance: 12000, instalment: 3000 })], 20000); + + expect(plan.total).toBe(3000); + expect(plan.repayments[0].remaining).toBe(9000); + }); + + it("never takes more than is owed", () => { + // The last instalment of a nearly-settled advance. + const plan = planRepayments([advance({ balance: 800, instalment: 3000 })], 20000); + + expect(plan.total).toBe(800); + expect(plan.repayments[0].remaining).toBe(0); + }); + + it("takes only what the pay can bear, and leaves the rest owing", () => { + // A bad month — unpaid absence ate the wage. Deducting the full instalment + // would make the payslip negative; forgiving it would write off the + // company's money without anybody deciding to. + const plan = planRepayments([advance({ balance: 10000, instalment: 4000 })], 1500); + + expect(plan.total).toBe(1500); + expect(plan.repayments[0].remaining).toBe(8500); + expect(plan.shortfall).toBe(true); + }); + + it("takes nothing at all when there is nothing to take", () => { + const plan = planRepayments([advance({ balance: 10000, instalment: 4000 })], 0); + + // Not a zero-amount repayment: on a payslip that reads as a transaction + // that happened, and it did not. + expect(plan.repayments).toEqual([]); + expect(plan.total).toBe(0); + expect(plan.shortfall).toBe(true); + }); + + it("never returns a negative repayment when pay is already negative", () => { + const plan = planRepayments([advance()], -500); + + expect(plan.total).toBe(0); + expect(plan.repayments).toEqual([]); + }); +}); + +describe("more than one advance", () => { + const two = [ + advance({ id: "new", balance: 3000, issuedOn: "2026-09-05" }), + advance({ id: "old", balance: 2000, issuedOn: "2026-08-01" }), + ]; + + it("pays the oldest debt first", () => { + // Any order gives the same total. A stable one means an employee can be + // told which debt a deduction paid, and a re-run produces the same payslip. + const plan = planRepayments(two, 100000); + + expect(plan.repayments.map((r) => r.advanceId)).toEqual(["old", "new"]); + expect(plan.total).toBe(5000); + }); + + it("stops when the money runs out, mid-way through the queue", () => { + const plan = planRepayments(two, 2500); + + expect(plan.repayments).toEqual([ + { advanceId: "old", amount: 2000, remaining: 0 }, + { advanceId: "new", amount: 500, remaining: 2500 }, + ]); + expect(plan.shortfall).toBe(true); + }); + + it("breaks a tie on issue date by id, so a re-run is identical", () => { + const sameDay = [ + advance({ id: "b", balance: 1000, issuedOn: "2026-09-01" }), + advance({ id: "a", balance: 1000, issuedOn: "2026-09-01" }), + ]; + + expect(planRepayments(sameDay, 100000).repayments.map((r) => r.advanceId)).toEqual(["a", "b"]); + }); + + it("ignores advances already settled", () => { + const plan = planRepayments([advance({ id: "done", balance: 0 }), advance({ id: "live" })], 100000); + + expect(plan.repayments.map((r) => r.advanceId)).toEqual(["live"]); + }); + + it("does no work and reports no shortfall when there is nothing outstanding", () => { + expect(planRepayments([], 20000)).toEqual({ repayments: [], total: 0, shortfall: false }); + }); +}); + +describe("the arithmetic itself", () => { + it("keeps to two decimals so a balance cannot drift", () => { + // Left unrounded, a third of a balance compounds every month until + // somebody is repaying a fraction of an afghani forever. + const plan = planRepayments([advance({ balance: 1000, instalment: 333.333 })], 20000); + + expect(plan.repayments[0].amount).toBe(333.33); + expect(plan.repayments[0].remaining).toBe(666.67); + }); + + it("settles exactly, with nothing left behind", () => { + let balance = 1000; + for (let month = 0; month < 3; month += 1) { + const plan = planRepayments([advance({ balance, instalment: 333.34 })], 20000); + balance = balanceAfter(balance, plan.total); + } + expect(balance).toBe(0); + }); + + it("never lets a balance go below zero", () => { + expect(balanceAfter(100, 250)).toBe(0); + }); +}); diff --git a/backend/functions/src/services/advances.ts b/backend/functions/src/services/advances.ts new file mode 100644 index 0000000..c0b8de5 --- /dev/null +++ b/backend/functions/src/services/advances.ts @@ -0,0 +1,120 @@ +/** + * Salary advances, and how they come back out of a payslip. + * + * In an Afghan business a worker taking money mid-month is the rule, not the + * exception. With nowhere to record it the accountant keeps a notebook and + * subtracts by hand at the end of the month — which is the job this product + * was bought to remove, and the place where a worker's pay is most likely to + * be wrong with nobody able to prove it either way. + * + * Everything here is pure. Payroll arithmetic is the one part of this system + * that takes money away from people, so the rules are written where they can + * be argued with in a test rather than discovered on a payslip. + */ + +export interface OutstandingAdvance { + id: string; + /** Still owed, in the company's currency. Never negative. */ + balance: number; + /** + * What to take per pay period. Null means "take it all at the next payroll", + * which is what a small advance a few days before payday should do. + */ + instalment: number | null; + /** ISO date the advance was given. Repayment order is oldest first. */ + issuedOn: string; +} + +export interface Repayment { + advanceId: string; + amount: number; + /** What is still owed after this payslip. Zero means it is settled. */ + remaining: number; +} + +export interface RepaymentPlan { + repayments: Repayment[]; + total: number; + /** + * True when pay could not cover everything due this period, so some of it + * rolls into next month. Worth surfacing: it usually means the advances were + * larger than the job can repay, which somebody should look at rather than + * discover three months running. + */ + shortfall: boolean; +} + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +/** + * How much of each advance to take from this payslip. + * + * Three rules, and the third is the one that matters: + * + * 1. Never more than is owed. An instalment larger than the remaining + * balance settles the advance and stops. + * + * 2. Oldest first. Any order gives the same total, but a stable one means an + * employee can be told which debt a deduction paid, and two runs of the + * same month produce the same payslip. + * + * 3. Never more than the pay can bear. If somebody had a bad month — unpaid + * absence, a short month — the deduction is reduced to what is left and + * the rest stays outstanding. The alternatives are both wrong: a negative + * payslip, or silently writing off money the company is owed. This is + * also the humane answer, because the floor it protects is the worker's + * pay reaching zero rather than going below it. + * + * `payAvailable` is what the payslip would otherwise pay out — gross less + * every other deduction. Advances come last on purpose: tax is owed to the + * state on what was earned, and an advance is the company's own money coming + * back, so it is the thing that yields when there is not enough to go round. + */ +export function planRepayments( + advances: readonly OutstandingAdvance[], + payAvailable: number, +): RepaymentPlan { + const budget = Math.max(0, round2(payAvailable)); + let left = budget; + const repayments: Repayment[] = []; + let anyUnpaid = false; + + const ordered = [...advances] + .filter((a) => a.balance > 0) + .sort((a, b) => (a.issuedOn === b.issuedOn ? a.id.localeCompare(b.id) : a.issuedOn.localeCompare(b.issuedOn))); + + for (const advance of ordered) { + const due = round2(Math.min(advance.instalment ?? advance.balance, advance.balance)); + const take = round2(Math.min(due, left)); + + if (take < due) anyUnpaid = true; + if (take <= 0) { + // No pay left. Say so rather than recording a zero repayment, which + // would read on a payslip as "we took nothing off this debt today" + // dressed up as a transaction. + continue; + } + + repayments.push({ advanceId: advance.id, amount: take, remaining: round2(advance.balance - take) }); + left = round2(left - take); + } + + return { + repayments, + total: round2(repayments.reduce((sum, r) => sum + r.amount, 0)), + shortfall: anyUnpaid, + }; +} + +/** + * The balance an advance is left with after a repayment is recorded. + * + * Trivial, and separate because it is the number the next payroll run reads. + * Getting it wrong by a rounding error compounds every month until somebody is + * paying off an afghani forever. + */ +export function balanceAfter(balance: number, repaid: number): number { + return Math.max(0, round2(balance - repaid)); +} diff --git a/backend/functions/src/services/approvals.integration.test.ts b/backend/functions/src/services/approvals.integration.test.ts new file mode 100644 index 0000000..2ad67b8 --- /dev/null +++ b/backend/functions/src/services/approvals.integration.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { db, tenant, nowTimestamp } from "../lib/firestore"; +import { listLeaveRequests } from "./leave"; +import { listRegularizations } from "./regularization"; + +/** + * The approvals queue has to agree with who is allowed to decide. + * + * A request is routed to `currentApproverId`, which comes from the employee's + * managerId — and the portal's employee form has no manager field, so in a + * company set up through the portal every request is unassigned. The decision + * path always let an administrator decide anything; the queue only ever showed + * requests addressed to the caller. So the administrator was told there was + * nothing to approve while requests piled up, and unapproved leave is deducted + * as unexcused absence — it cost employees money. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); +let cid = ""; +let seq = 0; + +const ADMIN = ["COMPANY_ADMIN"]; +const HR = ["HR_ADMIN"]; +const LEAD = ["TEAM_LEAD"]; + +async function leaveRequest( + id: string, + over: Record = {}, +): Promise { + await tenant(cid, "leaveRequests").doc(id).set({ + companyId: cid, + employeeId: "e_worker", + employeeName: "Worker", + leaveTypeId: "annual", + startDate: "2026-08-01", + endDate: "2026-08-02", + startHalfDay: false, + endHalfDay: false, + days: 2, + reason: "family", + status: "PENDING", + currentApproverId: null, // what the portal actually produces + decidedAt: null, + decisionNote: null, + createdAt: nowTimestamp(), + updatedAt: nowTimestamp(), + ...over, + }); +} + +async function correction(id: string, over: Record = {}): Promise { + await tenant(cid, "regularizations").doc(id).set({ + companyId: cid, + employeeId: "e_worker", + date: "2026-08-03", + reason: "forgot to check out", + status: "PENDING", + currentApproverId: null, + createdAt: nowTimestamp(), + updatedAt: nowTimestamp(), + ...over, + }); +} + +describe.skipIf(!EMULATOR)("the approvals queue", () => { + beforeEach(async () => { + seq += 1; + cid = `appr_${Date.now()}_${seq}`; + await db.collection("companies").doc(cid).set({ name: "Approvals Co" }); + }); + + it("shows an administrator a leave request that was routed to nobody", async () => { + await leaveRequest("lr_1"); + + const seen = await listLeaveRequests(cid, "e_admin", ADMIN, "approvals"); + expect(seen.map((r) => r.id)).toEqual(["lr_1"]); + }); + + it("shows an HR administrator the same", async () => { + await leaveRequest("lr_1"); + + const seen = await listLeaveRequests(cid, "e_hr", HR, "approvals"); + expect(seen.map((r) => r.id)).toEqual(["lr_1"]); + }); + + it("does not show an administrator requests that are already decided", async () => { + await leaveRequest("lr_pending"); + await leaveRequest("lr_done", { status: "APPROVED" }); + await leaveRequest("lr_no", { status: "REJECTED" }); + + const seen = await listLeaveRequests(cid, "e_admin", ADMIN, "approvals"); + expect(seen.map((r) => r.id)).toEqual(["lr_pending"]); + }); + + it("shows a team lead only what was routed to them", async () => { + // A lead may not decide anything they were not given, so the queue must not + // offer them work the server would refuse. + await leaveRequest("lr_theirs", { currentApproverId: "e_lead" }); + await leaveRequest("lr_unassigned"); + await leaveRequest("lr_someone_else", { currentApproverId: "e_other" }); + + const seen = await listLeaveRequests(cid, "e_lead", LEAD, "approvals"); + expect(seen.map((r) => r.id)).toEqual(["lr_theirs"]); + }); + + it("still gives everyone their own history under the default scope", async () => { + await leaveRequest("lr_mine", { employeeId: "e_admin", status: "APPROVED" }); + await leaveRequest("lr_theirs", { employeeId: "e_worker" }); + + const seen = await listLeaveRequests(cid, "e_admin", ADMIN, "mine"); + expect(seen.map((r) => r.id)).toEqual(["lr_mine"]); + }); + + it("shows an administrator an attendance correction routed to nobody", async () => { + await correction("rg_1"); + + const seen = await listRegularizations(cid, "e_admin", ADMIN, "approvals"); + expect(seen.map((r) => r.id)).toEqual(["rg_1"]); + }); + + it("shows a team lead only the corrections routed to them", async () => { + await correction("rg_theirs", { currentApproverId: "e_lead" }); + await correction("rg_unassigned"); + + const seen = await listRegularizations(cid, "e_lead", LEAD, "approvals"); + expect(seen.map((r) => r.id)).toEqual(["rg_theirs"]); + }); + + it("keeps a correction queue free of decided items", async () => { + await correction("rg_pending"); + await correction("rg_done", { status: "APPROVED" }); + + const seen = await listRegularizations(cid, "e_admin", ADMIN, "approvals"); + expect(seen.map((r) => r.id)).toEqual(["rg_pending"]); + }); +}); diff --git a/backend/functions/src/services/attendance.integration.test.ts b/backend/functions/src/services/attendance.integration.test.ts new file mode 100644 index 0000000..35712a6 --- /dev/null +++ b/backend/functions/src/services/attendance.integration.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Timestamp } from "firebase-admin/firestore"; +import { db, tenant } from "../lib/firestore"; +import { recomputeAttendanceDay } from "./attendance"; + +/** + * recomputeAttendanceDay against a real Firestore, because the bugs in it were + * never in the arithmetic — they were in which punches the query returned. + * attendance.test.ts pins the date helpers; only this can prove the projection + * actually uses them. + * + * Skipped unless a Firestore emulator is running: + * firebase emulators:exec --only firestore --project demo-worktrack \ + * "npx vitest run src/services/attendance.integration.test.ts" + */ + +const KABUL = "Asia/Kabul"; +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); + +let cid = ""; +let seq = 0; + +async function punch( + employeeId: string, + iso: string, + type: "IN" | "OUT", + extra: Record = {}, +): Promise { + await tenant(cid, "punches") + .doc(`p${String(seq++).padStart(4, "0")}`) + .set({ + companyId: cid, + employeeId, + punchedAt: Timestamp.fromDate(new Date(iso)), + type, + method: "GPS", + latitude: null, + longitude: null, + accuracyMeters: null, + geofenceId: null, + insideFence: false, + kioskId: null, + note: null, + serverValidated: true, + invalidReason: null, + updatedAt: Timestamp.now(), + ...extra, + }); +} + +async function dayOf(employeeId: string, date: string): Promise> { + const snap = await tenant(cid, "attendanceDays").doc(`${employeeId}_${date}`).get(); + expect(snap.exists, `attendanceDays/${employeeId}_${date} was not written`).toBe(true); + return snap.data() as Record; +} + +describe.skipIf(!EMULATOR)("recomputeAttendanceDay", () => { + beforeEach(async () => { + // A fresh tenant per test: no cross-test bleed, no cleanup to forget. + cid = `it_${Date.now()}_${seq}`; + await db.collection("companies").doc(cid).set({ name: "IT", timezone: KABUL }); + }); + + it("counts a night shift on the day it was worked", async () => { + // 01:00 and 03:00 on the 27th in Kabul — which is the evening of the 26th + // in UTC. A UTC-midnight window starts at 04:30 Kabul and misses both, + // writing an empty day for a shift that was actually worked. + await punch("e1", "2026-07-26T20:30:00Z", "IN"); + await punch("e1", "2026-07-26T22:30:00Z", "OUT"); + + await recomputeAttendanceDay(cid, "e1", "2026-07-27", KABUL); + + const day = await dayOf("e1", "2026-07-27"); + expect(day.workedMinutes).toBe(120); + expect(day.status).toBe("HALF_DAY"); + }); + + it("counts an ordinary daytime shift", async () => { + // 08:30 → 16:30 Kabul. + await punch("e1", "2026-07-26T04:00:00Z", "IN"); + await punch("e1", "2026-07-26T12:00:00Z", "OUT"); + + await recomputeAttendanceDay(cid, "e1", "2026-07-26", KABUL); + + const day = await dayOf("e1", "2026-07-26"); + expect(day.workedMinutes).toBe(480); + expect(day.status).toBe("PRESENT"); + }); + + it("treats an open session as present rather than absent", async () => { + await punch("e1", "2026-07-26T04:00:00Z", "IN"); + + await recomputeAttendanceDay(cid, "e1", "2026-07-26", KABUL); + + const day = await dayOf("e1", "2026-07-26"); + expect(day.status).toBe("PRESENT"); + expect(day.workedMinutes).toBe(0); + }); + + it("leaves a punch from the next local day out of this one", async () => { + await punch("e1", "2026-07-26T04:00:00Z", "IN"); + await punch("e1", "2026-07-26T12:00:00Z", "OUT"); + // 00:30 on the 27th in Kabul — belongs to the 27th, not the 26th. + await punch("e1", "2026-07-26T20:00:00Z", "IN"); + + await recomputeAttendanceDay(cid, "e1", "2026-07-26", KABUL); + + const day = await dayOf("e1", "2026-07-26"); + expect(day.workedMinutes).toBe(480); + // The late punch must not reopen the day as still-clocked-in. + expect(day.lastOutAt).not.toBeNull(); + }); + + it("excludes a refused punch from worked time but reports why", async () => { + await punch("e1", "2026-07-26T04:00:00Z", "IN"); + await punch("e1", "2026-07-26T12:00:00Z", "OUT"); + await punch("e1", "2026-07-26T13:00:00Z", "IN", { + serverValidated: false, + invalidReason: "GEOFENCE_VIOLATION", + }); + + await recomputeAttendanceDay(cid, "e1", "2026-07-26", KABUL); + + const day = await dayOf("e1", "2026-07-26"); + expect(day.workedMinutes).toBe(480); + expect(day.rejectedCount).toBe(1); + expect(day.rejectedReason).toBe("GEOFENCE_VIOLATION"); + }); + + it("keeps one employee's punches out of another's day", async () => { + await punch("e1", "2026-07-26T04:00:00Z", "IN"); + await punch("e1", "2026-07-26T12:00:00Z", "OUT"); + await punch("e2", "2026-07-26T05:00:00Z", "IN"); + + await recomputeAttendanceDay(cid, "e2", "2026-07-26", KABUL); + + const day = await dayOf("e2", "2026-07-26"); + expect(day.workedMinutes).toBe(0); + expect(day.status).toBe("PRESENT"); + }); +}); + +describe.skipIf(!EMULATOR)("night shifts", () => { + beforeEach(async () => { + cid = `ns_${Date.now()}_${seq}`; + await db.collection("companies").doc(cid).set({ name: "Night", timezone: KABUL }); + }); + + it("keeps a session with the day it started, not the day it ended", async () => { + // 22:00 on the 26th to 02:00 on the 27th, Kabul. The clock-out lands in the + // next day's window; the four hours belong to the 26th all the same. + await punch("e1", "2026-07-26T17:30:00Z", "IN"); + await punch("e1", "2026-07-26T21:30:00Z", "OUT"); + + await recomputeAttendanceDay(cid, "e1", "2026-07-26", KABUL); + const day = await dayOf("e1", "2026-07-26"); + + expect(day.workedMinutes).toBe(240); + expect(day.lastOutAt).not.toBeNull(); + }); + + it("does not also count that session on the day it ended", async () => { + await punch("e1", "2026-07-26T17:30:00Z", "IN"); + await punch("e1", "2026-07-26T21:30:00Z", "OUT"); + + await recomputeAttendanceDay(cid, "e1", "2026-07-27", KABUL); + const day = await dayOf("e1", "2026-07-27"); + + // The stray clock-out must not open or close anything here. + expect(day.workedMinutes).toBe(0); + expect(day.status).toBe("PENDING"); + }); + + it("does not treat an overnight shift as a full day of overtime", async () => { + await tenant(cid, "shifts").doc("night").set({ + startTime: "22:00", + endTime: "06:00", + graceInMinutes: 10, + graceOutMinutes: 10, + breakMinutes: 0, + }); + await tenant(cid, "shiftAssignments").doc("a1").set({ + employeeId: "e1", + date: "2026-07-26", + shiftId: "night", + }); + await punch("e1", "2026-07-26T17:30:00Z", "IN"); // 22:00 Kabul + await punch("e1", "2026-07-26T21:30:00Z", "OUT"); // 02:00 Kabul + + await recomputeAttendanceDay(cid, "e1", "2026-07-26", KABUL); + const day = await dayOf("e1", "2026-07-26"); + + // Scheduled is 8h; four hours worked is under it, so no overtime at all. + // The pre-fix arithmetic made scheduled negative and called all 240 overtime. + expect(day.overtimeMinutes).toBe(0); + }); +}); diff --git a/backend/functions/src/services/attendance.test.ts b/backend/functions/src/services/attendance.test.ts new file mode 100644 index 0000000..9ec48e9 --- /dev/null +++ b/backend/functions/src/services/attendance.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { localDateOf, localTimeToUtc, weekOf } from "./attendance"; + +/** + * The company-calendar maths behind attendance. Every bug found in this area + * has been a timezone boundary, so each case below is one of them, pinned: + * + * - a day filed under the viewer's date instead of the company's + * - a day window built from UTC midnight instead of local midnight, which + * dropped night-shift punches from the day they were worked + * + * Kabul is UTC+4:30 — a half-hour offset, which is exactly the kind that + * hour-based arithmetic gets wrong. + */ + +const KABUL = "Asia/Kabul"; +const OTTAWA = "America/Toronto"; + +describe("localDateOf — which calendar day a punch belongs to", () => { + it("uses the company's day, not the viewer's", () => { + // 20:23 in Ottawa on the 26th is already 04:53 on the 27th in Kabul. + const at = new Date("2026-07-27T00:23:53Z"); + expect(localDateOf(at, KABUL)).toBe("2026-07-27"); + expect(localDateOf(at, OTTAWA)).toBe("2026-07-26"); + }); + + it("rolls over at Kabul midnight, not at UTC midnight", () => { + // 19:29 UTC is 23:59 in Kabul — still the previous day. + expect(localDateOf(new Date("2026-07-26T19:29:00Z"), KABUL)).toBe("2026-07-26"); + // One minute later it is 00:00 in Kabul, so the day turns over. + expect(localDateOf(new Date("2026-07-26T19:30:00Z"), KABUL)).toBe("2026-07-27"); + }); + + it("agrees with UTC in the middle of the working day", () => { + expect(localDateOf(new Date("2026-07-26T08:00:00Z"), KABUL)).toBe("2026-07-26"); + }); +}); + +describe("weekOf — the working week a date belongs to", () => { + // 2026-07-25 is a Saturday; the week runs to Friday 2026-07-31. + const week = ["2026-07-25", "2026-07-26", "2026-07-27", "2026-07-28", "2026-07-29", "2026-07-30", "2026-07-31"]; + + it("starts on Saturday, the first working day here", () => { + expect(weekOf("2026-07-25")).toEqual(week); + }); + + it("gives the same week from any day inside it", () => { + // Sunday, midweek, and the Friday that closes it. + expect(weekOf("2026-07-26")).toEqual(week); + expect(weekOf("2026-07-28")).toEqual(week); + expect(weekOf("2026-07-31")).toEqual(week); + }); + + it("rolls to the next week on the following Saturday", () => { + expect(weekOf("2026-08-01")[0]).toBe("2026-08-01"); + }); + + it("crosses a month boundary without breaking", () => { + const w = weekOf("2026-08-01"); + expect(w).toHaveLength(7); + expect(w[w.length - 1]).toBe("2026-08-07"); + }); + + it("crosses a year boundary without breaking", () => { + const w = weekOf("2027-01-01"); // a Friday + expect(w[0]).toBe("2026-12-26"); + expect(w[6]).toBe("2027-01-01"); + }); +}); + +describe("localTimeToUtc — the window a day's punches are read from", () => { + it("puts local midnight at 19:30 UTC the previous day", () => { + // This is the fix: a UTC-midnight window would start at 04:30 Kabul and + // miss everything worked between midnight and dawn. + expect(localTimeToUtc("2026-07-27", "00:00", KABUL).toISOString()).toBe( + "2026-07-26T19:30:00.000Z", + ); + }); + + it("covers a night-shift punch made at 01:00 local", () => { + const start = localTimeToUtc("2026-07-27", "00:00", KABUL); + const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); + const punch = new Date("2026-07-26T20:30:00Z"); // 01:00 on the 27th in Kabul + + expect(punch >= start && punch < end).toBe(true); + // …and it is filed under the same day the window belongs to. + expect(localDateOf(punch, KABUL)).toBe("2026-07-27"); + }); + + it("excludes a punch from the following local day", () => { + const start = localTimeToUtc("2026-07-27", "00:00", KABUL); + const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); + const nextDay = new Date("2026-07-27T20:00:00Z"); // 00:30 on the 28th in Kabul + + expect(nextDay < end).toBe(false); + expect(localDateOf(nextDay, KABUL)).toBe("2026-07-28"); + }); + + it("converts a shift start time, not just midnight", () => { + // 08:00 Kabul is 03:30 UTC. + expect(localTimeToUtc("2026-07-26", "08:00", KABUL).toISOString()).toBe( + "2026-07-26T03:30:00.000Z", + ); + }); + + it("round-trips: local midnight resolves back to its own date", () => { + for (const date of ["2026-01-01", "2026-07-27", "2026-12-31"]) { + expect(localDateOf(localTimeToUtc(date, "00:00", KABUL), KABUL)).toBe(date); + } + }); + + it("handles a zone with daylight saving on both sides of the change", () => { + // Toronto is UTC-4 in July and UTC-5 in January; the offset must be taken + // at the instant in question, not assumed. + expect(localTimeToUtc("2026-07-15", "00:00", OTTAWA).toISOString()).toBe( + "2026-07-15T04:00:00.000Z", + ); + expect(localTimeToUtc("2026-01-15", "00:00", OTTAWA).toISOString()).toBe( + "2026-01-15T05:00:00.000Z", + ); + }); +}); diff --git a/backend/functions/src/services/attendance.ts b/backend/functions/src/services/attendance.ts new file mode 100644 index 0000000..397bdd1 --- /dev/null +++ b/backend/functions/src/services/attendance.ts @@ -0,0 +1,272 @@ +import { Timestamp } from "firebase-admin/firestore"; +import { nowTimestamp, tenant, toIso } from "../lib/firestore"; + +/** How far past a day's end a session started that day may still close. */ +const OVERNIGHT_LOOKAHEAD_MS = 12 * 60 * 60 * 1000; + +export interface PunchDoc { + companyId: string; + employeeId: string; + punchedAt: Timestamp; + type: "IN" | "OUT"; + method: string; + latitude: number | null; + longitude: number | null; + accuracyMeters: number | null; + geofenceId: string | null; + insideFence: boolean; + kioskId: string | null; + note: string | null; + selfie?: string | null; + /** Server-derived: true only when the punch carried a valid face token. */ + faceVerified?: boolean; + /** Set when the company expects face verification and this punch lacked it. */ + needsReview?: boolean; + reviewReason?: string | null; + serverValidated: boolean; + invalidReason: string | null; + updatedAt: Timestamp; +} + +export function punchToDto(id: string, doc: PunchDoc): Record { + return { + id, + companyId: doc.companyId, + employeeId: doc.employeeId, + punchedAt: toIso(doc.punchedAt), + type: doc.type, + method: doc.method, + latitude: doc.latitude, + longitude: doc.longitude, + accuracyMeters: doc.accuracyMeters, + geofenceId: doc.geofenceId, + insideFence: doc.insideFence, + note: doc.note, + faceVerified: doc.faceVerified ?? false, + needsReview: doc.needsReview ?? false, + reviewReason: doc.reviewReason ?? null, + serverValidated: doc.serverValidated, + invalidReason: doc.invalidReason, + updatedAt: toIso(doc.updatedAt), + }; +} + +/** + * Recomputes the AttendanceDay projection for one employee/date from the raw + * punch stream. Runs after each accepted punch; shift matching, late/overtime + * math against shift grace windows is applied when an assignment exists. + */ +export async function recomputeAttendanceDay( + cid: string, + employeeId: string, + dateIso: string, + timezone: string, +): Promise { + // The day is keyed by the company's calendar date, so its window has to be + // that date's local midnight — not UTC midnight. In Kabul (UTC+4:30) a UTC + // window would start at 04:30 local, dropping every night-shift punch made + // between midnight and dawn from the very day it belongs to. + const dayStart = localTimeToUtc(dateIso, "00:00", timezone); + const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); + + // A night shift clocks out after midnight, which is the NEXT day's window. A + // work session belongs to the day it started, so the read reaches past the + // boundary to find the closing punch; punches that open a session after the + // boundary are left to the day they actually belong to. + const lookaheadEnd = new Date(dayEnd.getTime() + OVERNIGHT_LOOKAHEAD_MS); + const punchesSnap = await tenant(cid, "punches") + .where("employeeId", "==", employeeId) + .where("punchedAt", ">=", Timestamp.fromDate(dayStart)) + .where("punchedAt", "<", Timestamp.fromDate(lookaheadEnd)) + .orderBy("punchedAt", "asc") + .get(); + + const allPunches = punchesSnap.docs.map((d) => d.data() as PunchDoc); + const ownedByDay = (p: PunchDoc): boolean => p.punchedAt.toMillis() < dayEnd.getTime(); + const punches = allPunches.filter((p) => p.serverValidated); + + // Punches the server refused (outside the geofence, bad device clock, …) are + // kept as evidence but excluded from the worked-time math above. Summarise + // them onto the day so the portal can show WHY a day looks empty, instead of + // silently reading as "absent" with no explanation anywhere. + const rejected = allPunches.filter((p) => ownedByDay(p) && !p.serverValidated); + const rejectedCount = rejected.length; + // Ordered by punchedAt asc, so this is the earliest refusal of the day. + const rejectedReason = rejected[0]?.invalidReason ?? null; + const rejectedAt = rejected[0]?.punchedAt ?? null; + + let workedMinutes = 0; + let firstInAt: Timestamp | null = null; + let lastOutAt: Timestamp | null = null; + let openIn: Timestamp | null = null; + let checkInSelfie: string | null = null; + let checkInFaceVerified = false; + // Any unverified punch in the day is worth a manager's attention, not just + // the first one — someone could verify at check-in and not at check-out. + let needsReview = false; + for (const punch of punches) { + const owned = ownedByDay(punch); + if (owned && punch.needsReview) needsReview = true; + if (punch.type === "IN") { + // An arrival past the boundary starts the next day, not this one. + if (!owned) break; + if (!firstInAt) { + firstInAt = punch.punchedAt; + checkInSelfie = punch.selfie ?? null; // the day carries the check-in photo + checkInFaceVerified = punch.faceVerified ?? false; + } + if (!openIn) openIn = punch.punchedAt; + } else if (openIn) { + workedMinutes += Math.floor( + (punch.punchedAt.toMillis() - openIn.toMillis()) / 60_000, + ); + lastOutAt = punch.punchedAt; + openIn = null; + } + } + + // Shift-aware late/early metrics when a roster assignment exists. + const assignmentSnap = await tenant(cid, "shiftAssignments") + .where("employeeId", "==", employeeId) + .where("date", "==", dateIso) + .limit(1) + .get(); + + let shiftId: string | null = null; + let lateMinutes = 0; + let earlyOutMinutes = 0; + let overtimeMinutes = 0; + + if (!assignmentSnap.empty && firstInAt) { + const assignment = assignmentSnap.docs[0].data() as { shiftId: string }; + shiftId = assignment.shiftId; + const shiftDoc = await tenant(cid, "shifts").doc(shiftId).get(); + if (shiftDoc.exists) { + const shift = shiftDoc.data() as { + startTime: string; // HH:mm in branch-local time + endTime: string; + graceInMinutes: number; + graceOutMinutes: number; + breakMinutes: number; + }; + const shiftStart = localTimeToUtc(dateIso, shift.startTime, timezone); + let shiftEnd = localTimeToUtc(dateIso, shift.endTime, timezone); + // 22:00–06:00 ends the next morning. Without this the scheduled length is + // negative, and every worked minute is counted as overtime. + if (shiftEnd.getTime() <= shiftStart.getTime()) { + shiftEnd = new Date(shiftEnd.getTime() + 24 * 60 * 60 * 1000); + } + + const lateBy = Math.floor((firstInAt.toMillis() - shiftStart.getTime()) / 60_000); + lateMinutes = Math.max(0, lateBy - shift.graceInMinutes); + + if (lastOutAt) { + const earlyBy = Math.floor((shiftEnd.getTime() - lastOutAt.toMillis()) / 60_000); + earlyOutMinutes = Math.max(0, earlyBy - shift.graceOutMinutes); + + const scheduled = + Math.floor((shiftEnd.getTime() - shiftStart.getTime()) / 60_000) - shift.breakMinutes; + overtimeMinutes = Math.max(0, workedMinutes - scheduled); + } + } + } + + // Someone who has checked in counts as PRESENT while their session is still + // open (currently at work). Only a completed day under half the standard + // hours is HALF_DAY. No valid check-in stays PENDING (counts as absent). + const stillCheckedIn = openIn !== null; + const status = firstInAt + ? (stillCheckedIn || workedMinutes >= 240 ? "PRESENT" : "HALF_DAY") + : "PENDING"; + + const dayId = `${employeeId}_${dateIso}`; + await tenant(cid, "attendanceDays") + .doc(dayId) + .set({ + employeeId, + date: dateIso, + shiftId, + firstInAt, + lastOutAt, + workedMinutes, + lateMinutes, + earlyOutMinutes, + overtimeMinutes, + status, + checkInSelfie, + checkInFaceVerified, + needsReview, + rejectedCount, + rejectedReason, + rejectedAt, + computedAt: nowTimestamp(), + updatedAt: nowTimestamp(), + }); +} + +/** + * Converts a local wall-clock HH:mm on a date to a UTC Date using the IANA + * timezone, correct across DST via Intl (no external tz library needed). + */ +export function localTimeToUtc(dateIso: string, hhmm: string, timezone: string): Date { + const [hours, minutes] = hhmm.split(":").map((v) => Number.parseInt(v, 10)); + const naive = new Date(`${dateIso}T${hhmm.padStart(5, "0")}:00Z`); + // Offset of the target zone at that moment, in minutes. + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + const parts = Object.fromEntries( + formatter.formatToParts(naive).map((p) => [p.type, p.value]), + ); + const zoned = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour === "24" ? "0" : parts.hour), + Number(parts.minute), + ); + const offsetMillis = zoned - naive.getTime(); + return new Date( + Date.UTC( + Number(dateIso.slice(0, 4)), + Number(dateIso.slice(5, 7)) - 1, + Number(dateIso.slice(8, 10)), + hours, + minutes, + ) - offsetMillis, + ); +} + +/** + * The seven dates of the working week containing [dateIso], Saturday first. + * + * Afghanistan works Saturday through Thursday with Friday off, so a week + * starting on Monday would split every working week across two reports. + * Operates on plain date strings, which carry no timezone to get wrong. + */ +export function weekOf(dateIso: string): string[] { + const anchor = new Date(`${dateIso}T00:00:00Z`); + // getUTCDay: Sunday=0 … Saturday=6, so this is "days since Saturday". + const sinceSaturday = (anchor.getUTCDay() + 1) % 7; + const saturday = new Date(anchor.getTime() - sinceSaturday * 86_400_000); + return Array.from({ length: 7 }, (_, i) => + new Date(saturday.getTime() + i * 86_400_000).toISOString().slice(0, 10), + ); +} + +/** Local calendar date (YYYY-MM-DD) of an instant in the given timezone. */ +export function localDateOf(at: Date, timezone: string): string { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + return formatter.format(at); // en-CA yields YYYY-MM-DD +} diff --git a/backend/functions/src/services/businessTypes.test.ts b/backend/functions/src/services/businessTypes.test.ts new file mode 100644 index 0000000..403b68c --- /dev/null +++ b/backend/functions/src/services/businessTypes.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import { + BASE_FEATURES, + BASE_POLICIES, + BUSINESS_TYPES, + BUSINESS_TYPE_IDS, + settingsForBusinessType, +} from "./businessTypes"; + +describe("the catalogue itself", () => { + it("offers a real choice rather than a token one", () => { + expect(BUSINESS_TYPES.length).toBeGreaterThanOrEqual(15); + }); + + it("has no duplicate ids", () => { + expect(new Set(BUSINESS_TYPE_IDS).size).toBe(BUSINESS_TYPE_IDS.length); + }); + + it("says why every type differs", () => { + // A default nobody explained is a default nobody can safely change. + for (const type of BUSINESS_TYPES) { + expect(type.because.length, `${type.id} has no reason`).toBeGreaterThan(20); + } + }); + + it("only ever states differences from the product defaults", () => { + // A preset that repeats a default silently pins it: change the product + // default later and fifteen types quietly keep the old one. + for (const type of BUSINESS_TYPES) { + for (const [key, value] of Object.entries(type.features)) { + // ...unless the type declares it is pinning that value on purpose. + if (type.pins?.includes(key as never)) continue; + expect( + value, + `${type.id} restates the default for features.${key} without declaring it in pins`, + ).not.toEqual(BASE_FEATURES[key as keyof typeof BASE_FEATURES]); + } + for (const [key, value] of Object.entries(type.policies)) { + expect( + JSON.stringify(value), + `${type.id} restates the default for policies.${key}`, + ).not.toEqual(JSON.stringify(BASE_POLICIES[key as keyof typeof BASE_POLICIES])); + } + } + }); + + it("pins the tailoring workshop's camera off, not merely defaults it", () => { + // If somebody ever flips the product default to true, this workshop must + // not quietly inherit it. Declaring the pin is what makes that a decision + // rather than an accident of ordering. + const tailoring = BUSINESS_TYPES.find((t) => t.id === "TAILORING")!; + expect(tailoring.pins).toContain("faceRecognition"); + expect(tailoring.features.faceRecognition).toBe(false); + }); + + it("never turns face recognition on for anybody", () => { + // Nobody gets biometrics switched on because of a dropdown. It is a + // decision a company makes about its own staff, in its own settings. + for (const type of BUSINESS_TYPES) { + expect(type.features.faceRecognition ?? false, `${type.id} enables faces`).toBe(false); + } + }); +}); + +describe("what a new company starts with", () => { + it("gives the product defaults when no type is chosen", () => { + // Signing up must never fail over a dropdown, and a company that predates + // this is not misconfigured. + expect(settingsForBusinessType(null).features).toEqual(BASE_FEATURES); + expect(settingsForBusinessType(undefined).policies).toEqual(BASE_POLICIES); + expect(settingsForBusinessType("SOMETHING_WE_REMOVED").features).toEqual(BASE_FEATURES); + }); + + it("keeps a construction company's fences, and widens its grace", () => { + const s = settingsForBusinessType("CONSTRUCTION"); + expect(s.features.geofencing).toBe(true); + expect(s.policies.lateGraceMinutes).toBe(20); + }); + + it("does not fence an office or a shop", () => { + // Both work at one address they already control. A fence there is noise. + expect(settingsForBusinessType("OFFICE").features.geofencing).toBe(false); + expect(settingsForBusinessType("RETAIL").features.geofencing).toBe(false); + }); + + it("gives a security company a twelve-hour day", () => { + expect(settingsForBusinessType("SECURITY").policies.standardDailyMinutes).toBe(720); + }); + + it("leaves a tailoring workshop no camera at the door", () => { + // The deliberate one. A workshop staffed by women may find a camera a + // reason not to buy the product at all. + const s = settingsForBusinessType("TAILORING"); + expect(s.features.faceRecognition).toBe(false); + expect(s.features.qrKiosk).toBe(false); + expect(s.features.geofencing).toBe(false); + }); + + it("gives an NGO the Thursday-Friday weekend it actually keeps", () => { + expect(settingsForBusinessType("NGO").policies.weekendDays).toEqual([4, 5]); + }); + + it("changes nothing it was not asked to change", () => { + // The preset is a patch, not a replacement: everything the type is silent + // about stays exactly as the product intended. + const s = settingsForBusinessType("SECURITY"); + expect(s.features).toEqual(BASE_FEATURES); + expect(s.policies.weekendDays).toEqual(BASE_POLICIES.weekendDays); + expect(s.policies.lateGraceMinutes).toBe(BASE_POLICIES.lateGraceMinutes); + }); + + it("hands back a fresh object each time", () => { + // Callers write into these before storing them; a shared object would let + // one company's signup edit the next one's defaults. + const a = settingsForBusinessType("OFFICE"); + a.features.payroll = false; + expect(settingsForBusinessType("OFFICE").features.payroll).toBe(true); + expect(BASE_FEATURES.payroll).toBe(true); + }); +}); diff --git a/backend/functions/src/services/businessTypes.ts b/backend/functions/src/services/businessTypes.ts new file mode 100644 index 0000000..66ba104 --- /dev/null +++ b/backend/functions/src/services/businessTypes.ts @@ -0,0 +1,225 @@ +/** + * What kind of business this is, and what that should assume on their behalf. + * + * A construction firm and a tailoring workshop need the same product set up + * differently, and neither of them wants to learn what a geofence is on the + * day they sign up. So signup asks one question it can answer well — what work + * do you do — and turns the answer into sensible defaults. + * + * --------------------------------------------------------------------------- + * THESE ARE PRESETS, NOT MODES. The distinction is the whole design. + * + * A preset is read once, at signup, and never again. Afterwards every setting + * is exactly as editable as it was before, and the company is an ordinary + * company. Nothing in the codebase branches on the type. + * + * The alternative — behaviour that keeps consulting the type — would turn one + * product into fifteen. Fifteen types across nine feature switches is a matrix + * nobody can test, where each bug reproduces for one kind of customer and + * nobody else, and support cannot tell which. The type is kept on the company + * for two honest reasons only: so it can be changed later (businesses change), + * and so we can see what we are actually selling to. + * --------------------------------------------------------------------------- + */ + +export interface FeatureDefaults { + shifts?: boolean; + leave?: boolean; + payroll?: boolean; + regularization?: boolean; + announcements?: boolean; + geofencing?: boolean; + qrKiosk?: boolean; + faceRecognition?: boolean; + finance?: boolean; +} + +export interface PolicyDefaults { + standardDailyMinutes?: number; + weekendDays?: number[]; + lateGraceMinutes?: number; + overtimeEnabled?: boolean; +} + +export interface BusinessType { + id: string; + /** Only the differences from the product defaults, so the diff is readable. */ + features: FeatureDefaults; + policies: PolicyDefaults; + /** + * Why this type differs, in one line. Not decoration: the next person to + * change a default should have to disagree with a stated reason rather than + * guess what the last one was thinking. + */ + because: string; + /** + * Settings this type restates even though they already match the product + * default — because for this kind of business the value must not follow the + * default if the default ever moves. + * + * Repeating a default is normally a mistake: it silently pins the old value + * for fifteen types the day somebody changes the product's mind. Sometimes + * pinning is exactly what is wanted, and then it has to be said out loud + * rather than looking like the mistake. + */ + pins?: readonly (keyof FeatureDefaults)[]; +} + +/** + * The product's own defaults, which every type starts from. + * Mirrors DEFAULT_SETTINGS in settings.ts and the signup batch. + */ +export const BASE_FEATURES: Required = { + shifts: true, + leave: true, + payroll: true, + regularization: true, + announcements: true, + geofencing: true, + qrKiosk: true, + faceRecognition: false, + finance: true, +}; + +export const BASE_POLICIES: Required = { + standardDailyMinutes: 480, + weekendDays: [5], + lateGraceMinutes: 10, + overtimeEnabled: true, +}; + +export const BUSINESS_TYPES: readonly BusinessType[] = [ + { + id: "OFFICE", + features: { geofencing: false, qrKiosk: false }, + policies: {}, + because: "An office knows where its staff are; a fence around a desk is noise.", + }, + { + id: "CONSTRUCTION", + features: {}, + policies: { lateGraceMinutes: 20 }, + because: + "Several sites, and a fence per site is the whole reason for buying this. " + + "Arriving at a site is not arriving at a door, so the grace is wider.", + }, + { + id: "TAILORING", + features: { geofencing: false, faceRecognition: false, qrKiosk: false }, + policies: {}, + pins: ["faceRecognition"], + because: + "One room, so a fence adds nothing. Face and photo check-in stay OFF and " + + "this is the deliberate case: a workshop staffed by women may find a camera " + + "at the door a reason not to buy the product at all, not a feature to enable. " + + "It can still be switched on by a company that wants it.", + }, + { + id: "RETAIL", + features: { geofencing: false }, + policies: { lateGraceMinutes: 5 }, + because: + "A shop opens at a time and somebody has to be standing in it, so lateness " + + "is measured tightly. One address, so no fence.", + }, + { + id: "WAREHOUSE", + features: {}, + policies: {}, + because: "A gate, shifts, and drivers who are not staff. The defaults fit.", + }, + { + id: "SECURITY", + features: {}, + policies: { standardDailyMinutes: 720 }, + because: + "Twelve-hour shifts at fixed posts. A QR code at each post is how a guard " + + "proves they were there, which is what the customer is buying.", + }, + { + id: "RESTAURANT", + features: { geofencing: false }, + policies: { lateGraceMinutes: 5 }, + because: "Split shifts at one address; the kitchen cannot open late.", + }, + { + id: "CLINIC", + features: { geofencing: false }, + policies: { lateGraceMinutes: 5 }, + because: "Night shifts and handovers, at one address. A shift that starts late has nobody covering it.", + }, + { + id: "SCHOOL", + features: { geofencing: false, qrKiosk: false }, + policies: { standardDailyMinutes: 300 }, + because: + "A teaching day is not an office day, and the thing that matters is whether " + + "the class was taught, not whether somebody sat until five.", + }, + { + id: "NGO", + features: {}, + policies: { weekendDays: [4, 5] }, + because: + "Donor-funded work is reported per project, and most keep a Thursday-Friday " + + "weekend. Timesheets here are a compliance obligation, not a convenience.", + }, + { + id: "EXCHANGE", + features: { geofencing: false, qrKiosk: false }, + policies: { lateGraceMinutes: 5 }, + because: "Few people, one counter, and everything turns on who was present.", + }, + { + id: "TRANSPORT", + features: { geofencing: false }, + policies: { lateGraceMinutes: 30 }, + because: + "A driver is meant to be somewhere else, so a fence at the office would flag " + + "every one of them, every day, for doing their job.", + }, + { + id: "PRODUCTION", + features: {}, + policies: {}, + because: "Shift-based production against a count. The defaults fit.", + }, + { + id: "AGRICULTURE", + features: { qrKiosk: false }, + policies: { lateGraceMinutes: 30 }, + because: + "Seasonal labour hired by the day, often with no phone between them. Light " + + "starts the day, not a clock.", + }, + { + id: "HOSPITALITY", + features: { geofencing: false }, + policies: {}, + because: "Housekeeping and reception shifts at one address.", + }, +] as const; + +export const BUSINESS_TYPE_IDS = BUSINESS_TYPES.map((t) => t.id); + +export function findBusinessType(id: string | null | undefined): BusinessType | null { + return BUSINESS_TYPES.find((t) => t.id === id) ?? null; +} + +/** + * The settings a company of this kind should start with. + * + * An unknown or missing type gives the product defaults rather than an error: + * a company that signed up before this existed is not misconfigured, and a + * signup must never fail over a dropdown. + */ +export function settingsForBusinessType(id: string | null | undefined): { + features: Required; + policies: Required; +} { + const type = findBusinessType(id); + return { + features: { ...BASE_FEATURES, ...(type?.features ?? {}) }, + policies: { ...BASE_POLICIES, ...(type?.policies ?? {}) }, + }; +} diff --git a/backend/functions/src/services/calendar.integration.test.ts b/backend/functions/src/services/calendar.integration.test.ts new file mode 100644 index 0000000..b8dfc8f --- /dev/null +++ b/backend/functions/src/services/calendar.integration.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { db, tenant } from "../lib/firestore"; +import { listHolidays, seedSolarHolidays, saveHoliday, deleteHoliday } from "./calendar"; + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); +let cid = ""; +let seq = 0; + +describe.skipIf(!EMULATOR)("holiday storage", () => { + beforeEach(async () => { + cid = `hol_${Date.now()}_${seq++}`; + await db.collection("companies").doc(cid).set({ name: "Holidays" }); + }); + + it("seeds the solar holidays for a year", async () => { + const added = await seedSolarHolidays(cid, 1405); + expect(added).toBe(2); + const hs = await listHolidays(cid); + expect(hs.map((h) => h.date)).toEqual(["2026-03-21", "2026-08-19"]); + }); + + it("does not duplicate on a second seed", async () => { + await seedSolarHolidays(cid, 1405); + expect(await seedSolarHolidays(cid, 1405)).toBe(0); + expect(await listHolidays(cid)).toHaveLength(2); + }); + + it("leaves an administrator's edit alone when reseeding", async () => { + await seedSolarHolidays(cid, 1405); + await saveHoliday(cid, { date: "2026-03-21", name: "نوروز — تعطیل دو روزه", paid: true }); + await seedSolarHolidays(cid, 1405); + const h = (await listHolidays(cid)).find((x) => x.date === "2026-03-21"); + expect(h?.name).toBe("نوروز — تعطیل دو روزه"); + }); + + it("corrects rather than duplicates when the same date is saved twice", async () => { + await saveHoliday(cid, { date: "2026-04-01", name: "اول", paid: true }); + await saveHoliday(cid, { date: "2026-04-01", name: "دوم", paid: false }); + const hs = await listHolidays(cid); + expect(hs).toHaveLength(1); + expect(hs[0].name).toBe("دوم"); + expect(hs[0].paid).toBe(false); + }); + + it("filters to a range", async () => { + await seedSolarHolidays(cid, 1405); + expect(await listHolidays(cid, "2026-06-01", "2026-12-31")).toHaveLength(1); + }); + + it("removes one", async () => { + await seedSolarHolidays(cid, 1405); + await deleteHoliday(cid, "2026-03-21"); + expect(await listHolidays(cid)).toHaveLength(1); + }); +}); diff --git a/backend/functions/src/services/calendar.test.ts b/backend/functions/src/services/calendar.test.ts new file mode 100644 index 0000000..0fdcc76 --- /dev/null +++ b/backend/functions/src/services/calendar.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from "vitest"; +import { + classifyDay, + eachDate, + expectedWorkingDays, + isoWeekday, + shamsiDateToIso, + solarHolidaysFor, +} from "./calendar"; + +const NONE = new Set(); +const FRIDAY_OFF = [5]; + +describe("weekday numbering", () => { + // Off-by-one here would move every company's weekend by a day. + it("numbers Monday through Sunday as 1..7", () => { + expect(isoWeekday("2026-08-24")).toBe(1); // Monday + expect(isoWeekday("2026-08-25")).toBe(2); + expect(isoWeekday("2026-08-26")).toBe(3); + expect(isoWeekday("2026-08-27")).toBe(4); + expect(isoWeekday("2026-08-28")).toBe(5); // Friday + expect(isoWeekday("2026-08-29")).toBe(6); + expect(isoWeekday("2026-08-30")).toBe(7); // Sunday + }); + + it("does not drift with the host timezone", () => { + // A plain date has no zone; reading it in local time would shift the + // weekday for anyone west of UTC. + const tz = process.env.TZ; + try { + process.env.TZ = "America/Toronto"; + expect(isoWeekday("2026-08-28")).toBe(5); + process.env.TZ = "Asia/Kabul"; + expect(isoWeekday("2026-08-28")).toBe(5); + } finally { + process.env.TZ = tz; + } + }); +}); + +describe("date ranges", () => { + it("includes both ends", () => { + expect(eachDate("2026-08-24", "2026-08-27")).toEqual([ + "2026-08-24", "2026-08-25", "2026-08-26", "2026-08-27", + ]); + }); + + it("returns the single day when both ends match", () => { + expect(eachDate("2026-08-24", "2026-08-24")).toEqual(["2026-08-24"]); + }); + + it("crosses a month boundary", () => { + expect(eachDate("2026-08-30", "2026-09-02")).toEqual([ + "2026-08-30", "2026-08-31", "2026-09-01", "2026-09-02", + ]); + }); + + it("survives a daylight-saving shift without dropping or repeating a day", () => { + // 2026-03-08 is the US spring-forward. Adding 24h in local time would skip. + const days = eachDate("2026-03-06", "2026-03-10"); + expect(days).toEqual(["2026-03-06","2026-03-07","2026-03-08","2026-03-09","2026-03-10"]); + expect(new Set(days).size).toBe(5); + }); +}); + +describe("classifying a day", () => { + it("calls Friday a weekend for an Afghan company", () => { + expect(classifyDay("2026-08-28", FRIDAY_OFF, NONE)).toBe("WEEKEND"); + }); + + it("calls an ordinary Tuesday a working day", () => { + expect(classifyDay("2026-08-25", FRIDAY_OFF, NONE)).toBe("WORKING"); + }); + + it("recognises a holiday on a working day", () => { + expect(classifyDay("2026-08-25", FRIDAY_OFF, new Set(["2026-08-25"]))).toBe("HOLIDAY"); + }); + + it("leaves a holiday that falls on the weekend as a weekend", () => { + // Nobody works either way; the distinction only matters for pay. + expect(classifyDay("2026-08-28", FRIDAY_OFF, new Set(["2026-08-28"]))).toBe("WEEKEND"); + }); + + it("honours a company that works Fridays and rests Sunday", () => { + expect(classifyDay("2026-08-28", [7], NONE)).toBe("WORKING"); + expect(classifyDay("2026-08-30", [7], NONE)).toBe("WEEKEND"); + }); + + it("treats a two-day weekend as two days off", () => { + expect(classifyDay("2026-08-28", [5, 6], NONE)).toBe("WEEKEND"); + expect(classifyDay("2026-08-29", [5, 6], NONE)).toBe("WEEKEND"); + expect(classifyDay("2026-08-30", [5, 6], NONE)).toBe("WORKING"); + }); + + it("treats an empty weekend list as a seven-day week", () => { + for (const d of eachDate("2026-08-24", "2026-08-30")) { + expect(classifyDay(d, [], NONE)).toBe("WORKING"); + } + }); +}); + +describe("expected working days", () => { + it("drops the weekend from a full week", () => { + const days = expectedWorkingDays("2026-08-24", "2026-08-30", FRIDAY_OFF, NONE); + expect(days).toHaveLength(6); + expect(days).not.toContain("2026-08-28"); + }); + + it("drops a holiday as well", () => { + const days = expectedWorkingDays( + "2026-08-24", "2026-08-30", FRIDAY_OFF, new Set(["2026-08-25"]), + ); + expect(days).toHaveLength(5); + expect(days).not.toContain("2026-08-25"); + }); + + it("counts a realistic Afghan working month", () => { + // 2026-08-01..08-31: 31 days, Fridays on the 7th, 14th, 21st, 28th. + const days = expectedWorkingDays("2026-08-01", "2026-08-31", FRIDAY_OFF, NONE); + expect(days).toHaveLength(27); + }); + + it("returns nothing when the whole range is closed", () => { + expect(expectedWorkingDays("2026-08-28", "2026-08-28", FRIDAY_OFF, NONE)).toEqual([]); + }); +}); + +describe("Afghan solar holidays", () => { + it("puts Nawroz on the first day of the Shamsi year", () => { + // 1 Hamal 1405 is 21 March 2026. + expect(shamsiDateToIso(1405, 1, 1)).toBe("2026-03-21"); + }); + + it("puts Independence Day on 28 Asad", () => { + expect(shamsiDateToIso(1405, 5, 28)).toBe("2026-08-19"); + }); + + it("generates both for a year, marked as recurring", () => { + const hs = solarHolidaysFor(1405); + expect(hs.map((h) => h.date)).toEqual(["2026-03-21", "2026-08-19"]); + expect(hs.every((h) => h.source === "SOLAR_RECURRING" && h.paid)).toBe(true); + }); + + it("moves with the year rather than repeating a fixed Gregorian date", () => { + const a = solarHolidaysFor(1405)[0].date; + const b = solarHolidaysFor(1406)[0].date; + expect(a).not.toBe(b); + expect(b > a).toBe(true); + }); + + it("does not invent the lunar holidays", () => { + // Eid dates are announced by moon sighting; a computed date would be wrong + // often enough to dock pay for a day people were told was a holiday. + const names = solarHolidaysFor(1405).map((h) => h.nameEn.toLowerCase()); + expect(names.some((n) => n.includes("eid"))).toBe(false); + }); +}); diff --git a/backend/functions/src/services/calendar.ts b/backend/functions/src/services/calendar.ts new file mode 100644 index 0000000..85170b5 --- /dev/null +++ b/backend/functions/src/services/calendar.ts @@ -0,0 +1,189 @@ +import { z } from "zod"; +import { nowTimestamp, tenant } from "../lib/firestore"; +import { shamsiMonthStartIso } from "../lib/shamsi"; + +/** + * The working calendar: which dates a company actually expects people to work. + * + * Until this existed the system had no notion of an expected working day. An + * attendanceDays document was written only when something *happened* — a punch, + * an approved leave, a correction — so three very different situations were + * indistinguishable, all of them simply "no document": + * + * - Friday, when nobody is meant to be there + * - Eid, when nobody is meant to be there + * - an employee who never showed up + * + * Payroll counted only the documents it found, so the third case was paid in + * full. And `policies.weekendDays`, which the settings screen lets a manager + * choose, was never read by anything. + * + * Everything in the first half of this file is pure so the rules can be tested + * without a database. + */ + +export type DayKind = "WORKING" | "WEEKEND" | "HOLIDAY"; + +export interface Holiday { + /** Observed Gregorian date, YYYY-MM-DD. */ + date: string; + name: string; + nameEn: string; + /** Paid holidays cost the employee nothing; unpaid ones are simply closed. */ + paid: boolean; + source: "SOLAR_RECURRING" | "MANUAL"; +} + +/** ISO weekday for a plain date: Monday = 1 … Sunday = 7. */ +export function isoWeekday(dateIso: string): number { + // Parsed as UTC on purpose: a plain date carries no timezone, and letting the + // host's zone interpret it shifts the weekday for anyone west of UTC. + const day = new Date(`${dateIso}T00:00:00Z`).getUTCDay(); // Sun=0 … Sat=6 + return ((day + 6) % 7) + 1; +} + +/** Every date from `fromIso` to `toIso` inclusive. */ +export function eachDate(fromIso: string, toIso: string): string[] { + const out: string[] = []; + const end = new Date(`${toIso}T00:00:00Z`).getTime(); + for ( + let t = new Date(`${fromIso}T00:00:00Z`).getTime(); + t <= end; + t += 86_400_000 + ) { + out.push(new Date(t).toISOString().slice(0, 10)); + } + return out; +} + +/** + * What kind of day this is. A holiday that lands on a weekend stays a WEEKEND — + * the distinction only matters for pay, and neither is worked. + */ +export function classifyDay( + dateIso: string, + weekendDays: number[], + holidays: ReadonlySet, +): DayKind { + if (weekendDays.includes(isoWeekday(dateIso))) return "WEEKEND"; + if (holidays.has(dateIso)) return "HOLIDAY"; + return "WORKING"; +} + +/** The dates in [fromIso, toIso] on which people are actually expected in. */ +export function expectedWorkingDays( + fromIso: string, + toIso: string, + weekendDays: number[], + holidays: ReadonlySet, +): string[] { + return eachDate(fromIso, toIso).filter( + (d) => classifyDay(d, weekendDays, holidays) === "WORKING", + ); +} + +// ------------------------------------------------------------ Afghan defaults + +/** + * Holidays fixed in the Solar Hijri calendar, so they can be generated for any + * year. Deliberately short. + * + * The religious holidays — Eid al-Fitr, Eid al-Adha, Ashura, Mawlid — follow the + * lunar calendar and in Afghanistan their observed dates are announced by moon + * sighting, days ahead. They are NOT generated here, because a computed date + * would be wrong often enough to dock somebody's pay for a day they were told + * was a holiday. An administrator adds them for the year from the portal. + * + * Companies differ on which days they close; these are seeded as ordinary + * editable entries, not as something the tenant is stuck with. + */ +export const SOLAR_HOLIDAYS: { month: number; day: number; name: string; nameEn: string }[] = [ + { month: 1, day: 1, name: "نوروز", nameEn: "Nawroz" }, + { month: 5, day: 28, name: "روز استقلال", nameEn: "Independence Day" }, +]; + +/** Gregorian ISO date of a Solar Hijri year/month/day. */ +export function shamsiDateToIso(year: number, month: number, day: number): string { + const monthStart = shamsiMonthStartIso(year, month); + const t = new Date(`${monthStart}T00:00:00Z`).getTime() + (day - 1) * 86_400_000; + return new Date(t).toISOString().slice(0, 10); +} + +export function solarHolidaysFor(shamsiYear: number): Holiday[] { + return SOLAR_HOLIDAYS.map((h) => ({ + date: shamsiDateToIso(shamsiYear, h.month, h.day), + name: h.name, + nameEn: h.nameEn, + paid: true, + source: "SOLAR_RECURRING" as const, + })); +} + +// ----------------------------------------------------------------- storage + +export const holidayWriteSchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD"), + name: z.string().min(1).max(80), + nameEn: z.string().max(80).nullish(), + paid: z.boolean().optional().default(true), +}); + +export async function listHolidays(cid: string, fromIso?: string, toIso?: string): Promise { + const snap = await tenant(cid, "holidays").limit(1000).get(); + return snap.docs + .map((d) => { + const v = d.data() as Partial; + return { + date: v.date ?? d.id, + name: v.name ?? "", + nameEn: v.nameEn ?? "", + paid: v.paid ?? true, + source: v.source ?? "MANUAL", + }; + }) + .filter((h) => (!fromIso || h.date >= fromIso) && (!toIso || h.date <= toIso)) + .sort((a, b) => a.date.localeCompare(b.date)); +} + +/** Holiday dates in a range, as a set for the pure helpers above. */ +export async function holidaySet(cid: string, fromIso: string, toIso: string): Promise> { + return new Set((await listHolidays(cid, fromIso, toIso)).map((h) => h.date)); +} + +export async function saveHoliday( + cid: string, + input: z.infer, +): Promise { + const holiday: Holiday = { + date: input.date, + name: input.name, + nameEn: input.nameEn ?? "", + paid: input.paid ?? true, + source: "MANUAL", + }; + // Keyed by date, so saving the same day twice corrects it instead of + // creating a second entry that would be counted twice. + await tenant(cid, "holidays").doc(input.date).set({ ...holiday, updatedAt: nowTimestamp() }); + return holiday; +} + +export async function deleteHoliday(cid: string, dateIso: string): Promise { + await tenant(cid, "holidays").doc(dateIso).delete(); +} + +/** + * Adds the generated solar holidays for a year, without touching anything an + * administrator has already entered or removed for those dates. + */ +export async function seedSolarHolidays(cid: string, shamsiYear: number): Promise { + const col = tenant(cid, "holidays"); + const candidates = solarHolidaysFor(shamsiYear); + let added = 0; + for (const h of candidates) { + const ref = col.doc(h.date); + if ((await ref.get()).exists) continue; + await ref.set({ ...h, updatedAt: nowTimestamp() }); + added += 1; + } + return added; +} diff --git a/backend/functions/src/services/companyDeletion.integration.test.ts b/backend/functions/src/services/companyDeletion.integration.test.ts new file mode 100644 index 0000000..c0144eb --- /dev/null +++ b/backend/functions/src/services/companyDeletion.integration.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { db, tenant } from "../lib/firestore"; +import { + allDocIds, + cancelDeletion, + companiesDueForPurge, + getDeletion, + purgeCompany, + requestDeletion, +} from "./companyDeletion"; + +/** + * Closing an account destroys a tenant's payroll history. These check that it + * takes a deliberate, matured request to get there — and that nothing short of + * that deletes anything. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); +const NAME = "شرکت ساختمانی کابل"; +let cid = ""; +let seq = 0; + +async function company(): Promise { + await db.collection("companies").doc(cid).set({ name: NAME, status: "ACTIVE" }); + await tenant(cid, "employees").doc("emp_1").set({ firstName: "A", lastName: "B", status: "ACTIVE" }); + await tenant(cid, "payslips").doc("p1").set({ net: 30000 }); +} + +describe.skipIf(!EMULATOR)("closing a company account", () => { + beforeEach(async () => { + cid = `del_${Date.now()}_${seq++}`; + await company(); + }); + + it("reports nothing scheduled to begin with", async () => { + expect((await getDeletion(cid)).status).toBe("NONE"); + }); + + it("refuses a request that does not type the company name", async () => { + await expect( + requestDeletion(cid, "admin", "COMPANY_ADMIN", { confirmName: "Some Other Co" }, "2026-08-01"), + ).rejects.toMatchObject({ status: 400 }); + expect((await getDeletion(cid)).status).toBe("NONE"); + }); + + it("accepts the name with stray whitespace around it", async () => { + const d = await requestDeletion( + cid, "admin", "COMPANY_ADMIN", { confirmName: ` ${NAME} ` }, "2026-08-01", + ); + expect(d.status).toBe("SCHEDULED"); + }); + + it("schedules the purge thirty days out and suspends the account now", async () => { + const d = await requestDeletion(cid, "admin", "COMPANY_ADMIN", { confirmName: NAME }, "2026-08-01"); + expect(d.purgeAfter).toBe("2026-08-31"); + + const snap = await db.collection("companies").doc(cid).get(); + // Suspended immediately, so nobody keeps filing into a tenant on its way out. + expect(snap.data()?.status).toBe("SUSPENDED"); + }); + + it("destroys nothing while the grace period runs", async () => { + await requestDeletion(cid, "admin", "COMPANY_ADMIN", { confirmName: NAME }, "2026-08-01"); + await expect(purgeCompany(cid, "2026-08-30")).rejects.toMatchObject({ status: 422 }); + + expect((await tenant(cid, "payslips").get()).size).toBe(1); + expect((await db.collection("companies").doc(cid).get()).exists).toBe(true); + }); + + it("gives everything back on cancel", async () => { + await requestDeletion(cid, "admin", "COMPANY_ADMIN", { confirmName: NAME }, "2026-08-01"); + await cancelDeletion(cid, "admin", "COMPANY_ADMIN"); + + expect((await getDeletion(cid)).status).toBe("NONE"); + const snap = await db.collection("companies").doc(cid).get(); + expect(snap.data()?.status).toBe("ACTIVE"); + expect((await tenant(cid, "payslips").get()).size).toBe(1); + }); + + it("refuses to cancel something nobody scheduled", async () => { + await expect(cancelDeletion(cid, "admin", "COMPANY_ADMIN")).rejects.toMatchObject({ status: 422 }); + }); + + it("refuses to purge a company nobody asked to close", async () => { + await expect(purgeCompany(cid, "2030-01-01")).rejects.toMatchObject({ status: 422 }); + expect((await db.collection("companies").doc(cid).get()).exists).toBe(true); + }); + + it("purges the whole tree once the grace period has elapsed", async () => { + await requestDeletion(cid, "admin", "COMPANY_ADMIN", { confirmName: NAME }, "2026-08-01"); + const result = await purgeCompany(cid, "2026-08-31"); + + expect(result.purged).toBe(true); + expect((await db.collection("companies").doc(cid).get()).exists).toBe(false); + // recursiveDelete has to take the subcollections too, or they are orphaned + // and keep a deleted company's payroll readable forever. + expect((await tenant(cid, "payslips").get()).size).toBe(0); + expect((await tenant(cid, "employees").get()).size).toBe(0); + }); + + it("collects every login, not just the first page", async () => { + // The purge deletes the tree straight after collecting the ids, so anything + // a single capped read left behind would be an orphaned Firebase Auth + // account — still carrying valid cid/eid claims — with no record left of + // which accounts to clean up. 1,050 crosses the 1,000-document page. + const writer = db.bulkWriter(); + for (let i = 0; i < 1050; i++) { + void writer.set(tenant(cid, "employees").doc(`emp_${String(i).padStart(5, "0")}`), { + firstName: "A", + lastName: String(i), + status: "ACTIVE", + }); + } + await writer.close(); + + const ids = await allDocIds(cid, "employees"); + // 1,050 seeded here plus the emp_1 the fixture creates. + expect(ids.length).toBe(1051); + expect(new Set(ids).size).toBe(1051); // no page overlap + }, 60_000); + + it("lists only the companies that are actually due", async () => { + await requestDeletion(cid, "admin", "COMPANY_ADMIN", { confirmName: NAME }, "2026-08-01"); + + expect(await companiesDueForPurge("2026-08-30")).not.toContain(cid); + expect(await companiesDueForPurge("2026-08-31")).toContain(cid); + }); + + it("drops a cancelled company off the due list", async () => { + await requestDeletion(cid, "admin", "COMPANY_ADMIN", { confirmName: NAME }, "2026-08-01"); + await cancelDeletion(cid, "admin", "COMPANY_ADMIN"); + expect(await companiesDueForPurge("2030-01-01")).not.toContain(cid); + }); +}); diff --git a/backend/functions/src/services/companyDeletion.test.ts b/backend/functions/src/services/companyDeletion.test.ts new file mode 100644 index 0000000..dc8931f --- /dev/null +++ b/backend/functions/src/services/companyDeletion.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { + addDays, + GRACE_DAYS, + isDueForPurge, + NOT_SCHEDULED, + type CompanyDeletion, +} from "./companyDeletion"; + +function scheduled(purgeAfter: string | null): CompanyDeletion { + return { + status: "SCHEDULED", + requestedAt: "2026-08-01T00:00:00.000Z", + requestedBy: "admin", + purgeAfter, + reason: null, + }; +} + +describe("grace period arithmetic", () => { + it("lands thirty days later", () => { + expect(addDays("2026-08-01", GRACE_DAYS)).toBe("2026-08-31"); + }); + + it("crosses a month boundary", () => { + expect(addDays("2026-08-25", 30)).toBe("2026-09-24"); + }); + + it("crosses a year boundary", () => { + expect(addDays("2026-12-20", 30)).toBe("2027-01-19"); + }); + + it("handles a leap day", () => { + expect(addDays("2028-02-27", 3)).toBe("2028-03-01"); + }); + + it("does not drift across a daylight-saving change", () => { + // Adding 24h in local time would lose or repeat an hour and could land a + // day early — which here means deleting a company a day too soon. + const tz = process.env.TZ; + try { + process.env.TZ = "America/Toronto"; + expect(addDays("2027-03-01", 30)).toBe("2027-03-31"); + } finally { + process.env.TZ = tz; + } + }); +}); + +/** + * Everything below decides whether a tenant's payroll history is destroyed. + * The rule is deliberately strict: anything that is not an explicit, matured, + * scheduled request is a refusal. + */ +describe("is this company due for purge", () => { + it("purges once the grace period has elapsed", () => { + expect(isDueForPurge(scheduled("2026-08-31"), "2026-08-31")).toBe(true); + expect(isDueForPurge(scheduled("2026-08-31"), "2026-09-05")).toBe(true); + }); + + it("refuses the day before", () => { + expect(isDueForPurge(scheduled("2026-08-31"), "2026-08-30")).toBe(false); + }); + + it("refuses a company nobody asked to close", () => { + expect(isDueForPurge(NOT_SCHEDULED, "2030-01-01")).toBe(false); + }); + + it("refuses a scheduled record with no date on it", () => { + expect(isDueForPurge(scheduled(null), "2030-01-01")).toBe(false); + }); + + it("refuses a record whose status was corrupted to something else", () => { + const odd = { ...scheduled("2026-01-01"), status: "PENDING" as unknown as "SCHEDULED" }; + expect(isDueForPurge(odd, "2030-01-01")).toBe(false); + }); + + it("refuses an empty object rather than treating it as permission", () => { + expect(isDueForPurge({} as CompanyDeletion, "2030-01-01")).toBe(false); + }); + + it("compares dates as dates, not as strings that happen to sort", () => { + // ISO dates sort correctly, but only zero-padded. Guard the padded form. + expect(isDueForPurge(scheduled("2026-09-01"), "2026-08-31")).toBe(false); + expect(isDueForPurge(scheduled("2026-09-01"), "2026-09-01")).toBe(true); + }); + + it("gives a full thirty days from the request", () => { + const requested = "2026-08-01"; + const due = addDays(requested, GRACE_DAYS); + expect(isDueForPurge(scheduled(due), addDays(requested, GRACE_DAYS - 1))).toBe(false); + expect(isDueForPurge(scheduled(due), due)).toBe(true); + }); +}); diff --git a/backend/functions/src/services/companyDeletion.ts b/backend/functions/src/services/companyDeletion.ts new file mode 100644 index 0000000..1fc7c95 --- /dev/null +++ b/backend/functions/src/services/companyDeletion.ts @@ -0,0 +1,252 @@ +import { FieldPath } from "firebase-admin/firestore"; +import { getAuth } from "firebase-admin/auth"; +import { z } from "zod"; +import { ApiError, ErrorCodes } from "../lib/errors"; +import { audit, db, nowTimestamp, tenant } from "../lib/firestore"; +import type { TenantCollection } from "../lib/firestore"; + +/** + * Closing a company account. + * + * Deleting a tenant destroys its payroll history, its attendance record and + * every employee file in it. That is not something to do on one click from a + * frustrated administrator, and in most places those records must be retained + * for years — so this schedules the deletion rather than performing it: + * + * 1. An administrator requests closure and types the company name to confirm. + * 2. The account is suspended immediately, so it is obvious something changed + * and nobody keeps filing attendance into a tenant that is on its way out. + * 3. Nothing is destroyed for a grace period, during which any administrator + * can cancel and get everything back untouched. + * 4. Only after that does a scheduled job purge it, and only then is anything + * irreversible. + */ + +/** How long a scheduled deletion can still be undone. */ +export const GRACE_DAYS = 30; + +export type DeletionStatus = "NONE" | "SCHEDULED"; + +export interface CompanyDeletion { + status: DeletionStatus; + requestedAt: string | null; + requestedBy: string | null; + /** Date from which the purge may run, YYYY-MM-DD. */ + purgeAfter: string | null; + reason: string | null; +} + +export const NOT_SCHEDULED: CompanyDeletion = { + status: "NONE", + requestedAt: null, + requestedBy: null, + purgeAfter: null, + reason: null, +}; + +export const deletionRequestSchema = z.object({ + /** The company's own name, typed back. Guards against a misclick. */ + confirmName: z.string().min(1), + reason: z.string().max(500).nullish(), +}); + +/** Date `days` after `fromIso`, as YYYY-MM-DD. */ +export function addDays(fromIso: string, days: number): string { + const t = new Date(`${fromIso}T00:00:00Z`).getTime() + days * 86_400_000; + return new Date(t).toISOString().slice(0, 10); +} + +/** + * Whether a purge may run. Deliberately strict: anything other than a scheduled + * request whose grace period has fully elapsed is refused, so a malformed or + * half-written record can never be read as permission to delete. + */ +export function isDueForPurge(deletion: CompanyDeletion, todayIso: string): boolean { + if (deletion.status !== "SCHEDULED") return false; + if (!deletion.purgeAfter) return false; + return todayIso >= deletion.purgeAfter; +} + +export async function getDeletion(cid: string): Promise { + const snap = await db.collection("companies").doc(cid).get(); + const d = snap.data()?.deletion as Partial | undefined; + if (!d || d.status !== "SCHEDULED") return NOT_SCHEDULED; + return { + status: "SCHEDULED", + requestedAt: d.requestedAt ?? null, + requestedBy: d.requestedBy ?? null, + purgeAfter: d.purgeAfter ?? null, + reason: d.reason ?? null, + }; +} + +export async function requestDeletion( + cid: string, + actorId: string, + actorRole: string, + input: z.infer, + todayIso: string, +): Promise { + const ref = db.collection("companies").doc(cid); + const snap = await ref.get(); + if (!snap.exists) throw ApiError.notFound("Company not found"); + + const name = (snap.data()?.name as string | undefined) ?? ""; + // Compared loosely on whitespace only — an administrator retyping their own + // company name should not be defeated by a stray space. + if (input.confirmName.trim() !== name.trim()) { + throw new ApiError( + 400, + ErrorCodes.VALIDATION_FAILED, + "Type the company name exactly to confirm closing the account", + { confirmName: "Does not match the company name" }, + ); + } + + const deletion: CompanyDeletion = { + status: "SCHEDULED", + requestedAt: new Date().toISOString(), + requestedBy: actorId, + purgeAfter: addDays(todayIso, GRACE_DAYS), + reason: input.reason ?? null, + }; + + await ref.set( + { deletion, status: "SUSPENDED", updatedAt: nowTimestamp() }, + { merge: true }, + ); + + await audit(cid, { + actorId, + actorRole, + action: "company.deletion.request", + resourceType: "companies", + resourceId: cid, + after: { purgeAfter: deletion.purgeAfter, reason: deletion.reason }, + }); + + return deletion; +} + +export async function cancelDeletion( + cid: string, + actorId: string, + actorRole: string, +): Promise { + const ref = db.collection("companies").doc(cid); + const current = await getDeletion(cid); + if (current.status !== "SCHEDULED") { + throw ApiError.business("INVALID_STATE", "This account is not scheduled for closure"); + } + + await ref.set( + { deletion: NOT_SCHEDULED, status: "ACTIVE", updatedAt: nowTimestamp() }, + { merge: true }, + ); + + await audit(cid, { + actorId, + actorRole, + action: "company.deletion.cancel", + resourceType: "companies", + resourceId: cid, + before: { purgeAfter: current.purgeAfter }, + }); + + return NOT_SCHEDULED; +} + +export interface PurgeResult { + companyId: string; + authUsersDeleted: number; + purged: boolean; +} + +/** + * Destroys the tenant. Refuses unless a scheduled request has actually come due, + * so neither a stray call nor a bug in a caller can delete a live company. + */ +/** + * Every document id in a tenant collection, paged to exhaustion. + * + * A single capped `.get()` would leave the overflow behind, and because the + * purge then destroys the tree, the ids of the accounts it missed would be + * unrecoverable — orphaned logins with valid claims and no record of them. + * Paging by document name is stable here: nothing writes to the tenant during + * a purge, and the read happens before anything is deleted. + */ +export async function allDocIds( + cid: string, + collection: TenantCollection, + keep: (data: FirebaseFirestore.DocumentData) => boolean = () => true, +): Promise { + const PAGE = 1000; + const ids: string[] = []; + let cursor: string | null = null; + for (;;) { + let q = tenant(cid, collection).orderBy(FieldPath.documentId()).limit(PAGE); + if (cursor !== null) q = q.startAfter(cursor); + const snap: FirebaseFirestore.QuerySnapshot = await q.get(); + if (snap.empty) break; + for (const doc of snap.docs) if (keep(doc.data())) ids.push(doc.id); + if (snap.size < PAGE) break; + cursor = snap.docs[snap.docs.length - 1].id; + } + return ids; +} + +export async function purgeCompany(cid: string, todayIso: string): Promise { + const deletion = await getDeletion(cid); + if (!isDueForPurge(deletion, todayIso)) { + throw ApiError.business( + "INVALID_STATE", + "This account is not due for purge; nothing was deleted", + ); + } + + // Collect the logins BEFORE the tree goes, or there is no way left to find + // them: an auth user carries the company only in its custom claims, and + // listing every user of the project to filter them is not workable at size. + const auth = getAuth(); + const [employees, devices] = await Promise.all([ + allDocIds(cid, "employees"), + allDocIds(cid, "devices", (d) => d.type === "KIOSK"), + ]); + // Kiosk accounts are real logins too, keyed by the device id. + const uids = [...employees, ...devices]; + + let authUsersDeleted = 0; + for (let i = 0; i < uids.length; i += 1000) { + const batch = uids.slice(i, i + 1000); + const result = await auth.deleteUsers(batch); + authUsersDeleted += batch.length - result.failureCount; + if (result.failureCount > 0) { + console.warn( + "COMPANY_PURGE_AUTH_FAILURES", + cid, + result.errors.slice(0, 5).map((e) => e.error.message).join("; "), + ); + } + } + + // recursiveDelete walks the subcollections, which a plain delete would orphan. + await db.recursiveDelete(db.collection("companies").doc(cid)); + + console.warn("COMPANY_PURGED", JSON.stringify({ companyId: cid, authUsersDeleted })); + return { companyId: cid, authUsersDeleted, purged: true }; +} + +/** Companies whose grace period has elapsed. */ +export async function companiesDueForPurge(todayIso: string): Promise { + const snap = await db + .collection("companies") + .where("deletion.status", "==", "SCHEDULED") + .limit(500) + .get(); + return snap.docs + .filter((d) => { + const del = d.data().deletion as Partial | undefined; + return isDueForPurge({ ...NOT_SCHEDULED, ...del, status: "SCHEDULED" }, todayIso); + }) + .map((d) => d.id); +} diff --git a/backend/functions/src/services/crm.ts b/backend/functions/src/services/crm.ts new file mode 100644 index 0000000..02ac176 --- /dev/null +++ b/backend/functions/src/services/crm.ts @@ -0,0 +1,300 @@ +import { z } from "zod"; +import { db, nowTimestamp, toIso } from "../lib/firestore"; +import { ulid } from "../lib/ids"; + +/** + * The vendor's own record of the people it sells to. + * + * This is Linumic's data about its customers, not any customer's data, so it + * lives in top-level collections outside every tenant. A company closing its + * account purges its own tree; the vendor's memory of the deal survives that, + * as it must for an invoice or a dispute. + * + * Flat collections rather than subcollections under an account, because the + * questions that matter cut across accounts: what is due this week, what is + * unpaid, which tickets are open. A subcollection per account would make each + * of those a fan-out. + * + * An account may or may not point at a live tenant. Before the sale it does + * not — that is the whole point of a pipeline — and `companyId` is filled in + * when they become a customer. + */ + +/* ------------------------------------------------------------------ accounts */ + +export const ACCOUNT_STAGES = [ + "LEAD", + "CONTACTED", + "DEMO", + "QUOTED", + "WON", + "LOST", + "DORMANT", +] as const; +export type AccountStage = (typeof ACCOUNT_STAGES)[number]; + +export const accountWriteSchema = z.object({ + name: z.string().min(1).max(120), + stage: z.enum(ACCOUNT_STAGES).default("LEAD"), + /** Set once they are a paying tenant; links this record to the live company. */ + companyId: z.string().max(64).nullish(), + city: z.string().max(80).nullish(), + industry: z.string().max(80).nullish(), + /** Where they came from: a referral, the website, the demo, a visit. */ + source: z.string().max(80).nullish(), + /** Their own estimate of headcount, before they are onboarded. */ + employeesEstimate: z.number().int().min(0).max(1_000_000).nullish(), + /** The next thing the vendor must do, and when. The heart of following up. */ + nextActionAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullish(), + nextAction: z.string().max(200).nullish(), + notes: z.string().max(4000).nullish(), +}); +export type AccountWrite = z.infer; + +/* ------------------------------------------------------------------ contacts */ + +export const contactWriteSchema = z.object({ + accountId: z.string().min(1).max(64), + name: z.string().min(1).max(120), + role: z.string().max(80).nullish(), + /** Phone first: this market runs on calls, not email. */ + phone: z.string().max(40).nullish(), + email: z.string().max(160).nullish(), + /** The person decisions actually go through. */ + primary: z.boolean().default(false), + notes: z.string().max(1000).nullish(), +}); + +/* ---------------------------------------------------------------- activities */ + +export const ACTIVITY_KINDS = ["CALL", "MEETING", "MESSAGE", "EMAIL", "VISIT", "NOTE"] as const; + +export const activityWriteSchema = z.object({ + accountId: z.string().min(1).max(64), + kind: z.enum(ACTIVITY_KINDS).default("NOTE"), + at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + summary: z.string().min(1).max(2000), + contactId: z.string().max(64).nullish(), +}); + +/* --------------------------------------------------------------------- deals */ + +export const DEAL_STATUSES = ["DRAFT", "SENT", "ACCEPTED", "REJECTED"] as const; + +export const dealWriteSchema = z.object({ + accountId: z.string().min(1).max(64), + status: z.enum(DEAL_STATUSES).default("DRAFT"), + plan: z.enum(["FREE", "STANDARD", "ENTERPRISE"]).default("STANDARD"), + seats: z.number().int().min(1).max(100_000), + /** AFN. There is no payment rail here; this is what was agreed, in writing. */ + amountAfn: z.number().min(0).max(1_000_000_000), + /** MONTHLY or YEARLY — what the amount covers. */ + term: z.enum(["MONTHLY", "YEARLY", "ONE_OFF"]).default("YEARLY"), + quotedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullish(), + notes: z.string().max(2000).nullish(), +}); + +/* ------------------------------------------------------------------ invoices */ + +export const INVOICE_STATUSES = ["DRAFT", "SENT", "PAID", "VOID"] as const; +export const PAYMENT_METHODS = ["BANK", "CASH", "HAWALA", "OTHER"] as const; + +export const invoiceWriteSchema = z.object({ + accountId: z.string().min(1).max(64), + number: z.string().min(1).max(40), + status: z.enum(INVOICE_STATUSES).default("DRAFT"), + amountAfn: z.number().min(0).max(1_000_000_000), + issuedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + dueAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullish(), + paidAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullish(), + method: z.enum(PAYMENT_METHODS).nullish(), + /** Covers which period, in the vendor's own words. */ + period: z.string().max(80).nullish(), + notes: z.string().max(1000).nullish(), +}); + +/* ------------------------------------------------------------------- tickets */ + +export const TICKET_STATUSES = ["OPEN", "WAITING", "RESOLVED"] as const; +export const TICKET_PRIORITIES = ["LOW", "NORMAL", "HIGH", "URGENT"] as const; + +export const ticketWriteSchema = z.object({ + accountId: z.string().min(1).max(64), + subject: z.string().min(1).max(200), + status: z.enum(TICKET_STATUSES).default("OPEN"), + priority: z.enum(TICKET_PRIORITIES).default("NORMAL"), + openedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + resolvedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullish(), + detail: z.string().max(4000).nullish(), + resolution: z.string().max(4000).nullish(), +}); + +/* ------------------------------------------------------------------- storage */ + +/** The CRM's collections. Top-level: this is the vendor's data, not a tenant's. */ +export type CrmCollection = + | "crmAccounts" + | "crmContacts" + | "crmActivities" + | "crmDeals" + | "crmInvoices" + | "crmTickets"; + +function col(name: CrmCollection): FirebaseFirestore.CollectionReference { + return db.collection(name); +} + +function shape(doc: FirebaseFirestore.DocumentSnapshot): Record { + const d = doc.data() ?? {}; + return { + id: doc.id, + ...d, + createdAt: toIso(d.createdAt ?? null), + updatedAt: toIso(d.updatedAt ?? null), + }; +} + +export async function create( + collection: CrmCollection, + data: Record, + actor: string, +): Promise> { + const id = ulid(); + const now = nowTimestamp(); + await col(collection) + .doc(id) + .create({ ...data, createdBy: actor, createdAt: now, updatedAt: now }); + return shape(await col(collection).doc(id).get()); +} + +export async function update( + collection: CrmCollection, + id: string, + data: Record, + actor: string, +): Promise | null> { + const ref = col(collection).doc(id); + if (!(await ref.get()).exists) return null; + await ref.set({ ...data, updatedBy: actor, updatedAt: nowTimestamp() }, { merge: true }); + return shape(await ref.get()); +} + +export async function remove(collection: CrmCollection, id: string): Promise { + const ref = col(collection).doc(id); + if (!(await ref.get()).exists) return false; + await ref.delete(); + return true; +} + +export async function get( + collection: CrmCollection, + id: string, +): Promise | null> { + const doc = await col(collection).doc(id).get(); + return doc.exists ? shape(doc) : null; +} + +/** Everything in a collection, or everything belonging to one account. */ +export async function list( + collection: CrmCollection, + accountId?: string, +): Promise>> { + let q: FirebaseFirestore.Query = col(collection); + if (accountId) q = q.where("accountId", "==", accountId); + const snap = await q.limit(2000).get(); + return snap.docs.map(shape); +} + +/** + * Deleting an account takes its contacts, activities, deals, invoices and + * tickets with it. Leaving them behind would keep them in every cross-account + * view — unpaid invoices for a company that is no longer listed — with no way + * to reach them. + */ +export async function deleteAccountCascade(accountId: string): Promise { + const children: CrmCollection[] = [ + "crmContacts", + "crmActivities", + "crmDeals", + "crmInvoices", + "crmTickets", + ]; + let removed = 0; + for (const c of children) { + const snap = await col(c).where("accountId", "==", accountId).limit(2000).get(); + const batch = db.batch(); + snap.docs.forEach((d) => batch.delete(d.ref)); + if (snap.size) await batch.commit(); + removed += snap.size; + } + await col("crmAccounts").doc(accountId).delete(); + return removed; +} + +/* ----------------------------------------------------------------- dashboard */ + +export interface CrmDashboard { + /** Follow-ups whose date has arrived or passed. */ + dueNow: Array>; + /** Follow-ups in the next seven days. */ + dueSoon: Array>; + /** Sent but not paid, oldest first. */ + unpaidInvoices: Array>; + openTickets: Array>; + /** Count of accounts in each stage. */ + pipeline: Record; + /** AFN in quotes that have been sent but not decided. */ + openPipelineAfn: number; + /** AFN invoiced and unpaid. */ + outstandingAfn: number; +} + +export async function dashboard(todayIso: string): Promise { + const [accounts, invoices, tickets, deals] = await Promise.all([ + list("crmAccounts"), + list("crmInvoices"), + list("crmTickets"), + list("crmDeals"), + ]); + + const inSevenDays = new Date(Date.parse(`${todayIso}T00:00:00Z`) + 7 * 86_400_000) + .toISOString() + .slice(0, 10); + + const withAction = accounts.filter((a) => typeof a.nextActionAt === "string"); + const dueNow = withAction + .filter((a) => (a.nextActionAt as string) <= todayIso) + .sort((a, b) => String(a.nextActionAt).localeCompare(String(b.nextActionAt))); + const dueSoon = withAction + .filter( + (a) => (a.nextActionAt as string) > todayIso && (a.nextActionAt as string) <= inSevenDays, + ) + .sort((a, b) => String(a.nextActionAt).localeCompare(String(b.nextActionAt))); + + const unpaidInvoices = invoices + .filter((i) => i.status === "SENT") + .sort((a, b) => String(a.dueAt ?? a.issuedAt).localeCompare(String(b.dueAt ?? b.issuedAt))); + + const openTickets = tickets + .filter((t) => t.status !== "RESOLVED") + .sort((a, b) => String(a.openedAt).localeCompare(String(b.openedAt))); + + const pipeline: Record = {}; + for (const stage of ACCOUNT_STAGES) pipeline[stage] = 0; + for (const a of accounts) { + const s = String(a.stage ?? "LEAD"); + pipeline[s] = (pipeline[s] ?? 0) + 1; + } + + return { + dueNow, + dueSoon, + unpaidInvoices, + openTickets, + pipeline, + openPipelineAfn: deals + .filter((d) => d.status === "SENT") + .reduce((s, d) => s + Number(d.amountAfn ?? 0), 0), + outstandingAfn: unpaidInvoices.reduce((s, i) => s + Number(i.amountAfn ?? 0), 0), + }; +} diff --git a/backend/functions/src/services/demo-reset.test.ts b/backend/functions/src/services/demo-reset.test.ts new file mode 100644 index 0000000..96ca0c5 --- /dev/null +++ b/backend/functions/src/services/demo-reset.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { assertSafeToReset, DemoResetRefused, chunk } from "./demo-reset"; + +/** + * The reset deletes an entire company. The only thing between it and a real + * tenant is this guard, so it is tested harder than the thing it guards. + */ +describe("demo reset guard", () => { + it("allows the demo project", () => { + expect(assertSafeToReset("worktrack-demo-af")).toBe("worktrack-demo-af"); + }); + + it("refuses production", () => { + expect(() => assertSafeToReset("worktrack-prod")).toThrow(DemoResetRefused); + }); + + it("refuses anything that merely reads as live", () => { + for (const id of ["acme-production", "my-live-app", "WORKTRACK-PROD", "app-Live-2"]) { + expect(() => assertSafeToReset(id), id).toThrow(DemoResetRefused); + } + }); + + it("refuses another company's project even when the name looks harmless", () => { + // Deleting the wrong tenant is just as bad when the project is not "prod". + for (const id of ["talar-af-prod", "safebeauty", "stealthapp-b10f4", "worktrack-demo"]) { + expect(() => assertSafeToReset(id), id).toThrow(DemoResetRefused); + } + }); + + it("refuses when it cannot tell where it is", () => { + // Failing closed: an unknown project is a reason not to delete, not a + // reason to assume the best. + expect(() => assertSafeToReset("")).toThrow(DemoResetRefused); + }); + + it("does not accept a project that merely contains the demo name", () => { + for (const id of ["worktrack-demo-af-2", "x-worktrack-demo-af", "worktrack-demo-af.appspot.com"]) { + expect(() => assertSafeToReset(id), id).toThrow(DemoResetRefused); + } + }); +}); + +/** + * Auth deletion is batched because deleteUsers takes at most 1000 at a time. + * A batching bug here would leave visitor accounts behind and the reset would + * quietly stop being a reset. + */ +describe("auth deletion batching", () => { + it("keeps a short list in one batch", () => { + expect(chunk([1, 2, 3], 1000)).toEqual([[1, 2, 3]]); + }); + + it("splits exactly on the boundary without an empty trailing batch", () => { + const batches = chunk(Array.from({ length: 2000 }, (_, i) => i), 1000); + expect(batches).toHaveLength(2); + expect(batches[0]).toHaveLength(1000); + expect(batches[1]).toHaveLength(1000); + }); + + it("puts the remainder in a final short batch", () => { + const batches = chunk(Array.from({ length: 2001 }, (_, i) => i), 1000); + expect(batches).toHaveLength(3); + expect(batches[2]).toEqual([2000]); + }); + + it("loses nothing across batches", () => { + const input = Array.from({ length: 4321 }, (_, i) => i); + expect(chunk(input, 1000).flat()).toEqual(input); + }); + + it("has nothing to do for an empty list", () => { + expect(chunk([], 1000)).toEqual([]); + }); + + it("refuses a zero size rather than looping forever", () => { + expect(() => chunk([1, 2], 0)).toThrow(RangeError); + }); +}); diff --git a/backend/functions/src/services/demo-reset.ts b/backend/functions/src/services/demo-reset.ts new file mode 100644 index 0000000..b21c0a7 --- /dev/null +++ b/backend/functions/src/services/demo-reset.ts @@ -0,0 +1,144 @@ +import { getAuth } from "firebase-admin/auth"; +import { db } from "../lib/firestore"; + +/** + * Nightly reset of the public demo tenant. + * + * The demo is open to anyone, so it fills up with whatever visitors type. This + * wipes the tenant and lays the seeded company down again, so the next visitor + * sees the same clean set of people, shifts, attendance and payslips. + * + * The guard below is the important part of this file. This function deletes an + * entire company, and the only thing standing between it and a real tenant is + * the project it happens to be deployed in — so it refuses to run anywhere that + * looks live, and refuses anywhere it has not been explicitly told it belongs. + */ + +/** Anything that reads as a live tenant. Matched case-insensitively. */ +const LOOKS_LIVE = /prod|production|live/i; + +/** The only project this is allowed to touch. */ +const DEMO_PROJECT = "worktrack-demo-af"; + +export class DemoResetRefused extends Error {} + +function currentProjectId(): string { + return ( + process.env.GCLOUD_PROJECT || + process.env.GOOGLE_CLOUD_PROJECT || + JSON.parse(process.env.FIREBASE_CONFIG || "{}").projectId || + "" + ); +} + +/** + * Throws unless we are certainly in the demo project. Deliberately fails closed + * on an unknown project id: not being able to tell where we are is itself a + * reason not to delete anything. + */ +export function assertSafeToReset(projectId = currentProjectId()): string { + if (!projectId) { + throw new DemoResetRefused("Refusing to reset: the project id is unknown"); + } + if (LOOKS_LIVE.test(projectId)) { + throw new DemoResetRefused(`Refusing to reset "${projectId}": it reads as a live project`); + } + if (projectId !== DEMO_PROJECT) { + throw new DemoResetRefused( + `Refusing to reset "${projectId}": only ${DEMO_PROJECT} may be reset`, + ); + } + return projectId; +} + +export interface ResetOutcome { + projectId: string; + companyDeleted: boolean; + /** Seeded logins removed and recreated. */ + seedUsersDeleted: number; + /** Accounts visitors signed up with, which the seed does not own. */ + visitorUsersDeleted: number; + seeded: boolean; +} + +/** Splits a list into batches; deleteUsers accepts at most 1000 at a time. */ +export function chunk(items: T[], size: number): T[][] { + if (size < 1) throw new RangeError("chunk size must be at least 1"); + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + +const DELETE_BATCH = 1000; + +/** + * Removes every account in the project, seeded and visitor-created alike; the + * seed recreates its four immediately afterwards. + * + * Every uid is collected before anything is deleted. Deleting while paging + * would shift the page boundaries underneath the cursor and silently skip + * accounts, which is how a "reset" quietly stops being one. + */ +async function deleteAllAuthUsers(seedUids: string[]): Promise<{ seeded: number; visitors: number }> { + const auth = getAuth(); + const seedSet = new Set(seedUids); + const uids: string[] = []; + + let pageToken: string | undefined; + do { + const page = await auth.listUsers(1000, pageToken); + for (const user of page.users) uids.push(user.uid); + pageToken = page.pageToken; + } while (pageToken); + + let seeded = 0; + let visitors = 0; + for (const uid of uids) { + if (seedSet.has(uid)) seeded += 1; + else visitors += 1; + } + + for (const batch of chunk(uids, DELETE_BATCH)) { + const result = await auth.deleteUsers(batch); + if (result.failureCount > 0) { + // Reported rather than thrown: a handful of stubborn accounts must not + // stop the tenant from being reseeded. + console.warn( + "DEMO_RESET_AUTH_FAILURES", + result.errors.slice(0, 5).map((e) => e.error.message).join("; "), + ); + } + } + + return { seeded, visitors }; +} + +export async function resetDemoTenant(): Promise { + const projectId = assertSafeToReset(); + + // Imported lazily and only after the guard has passed, so requiring the seed + // can never be a side effect of loading this module. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const seed = require("../../seed.js") as { + seedDemoTenant: () => Promise; + CID: string; + DEMO_UIDS: string[]; + }; + + const companyRef = db.collection("companies").doc(seed.CID); + // recursiveDelete walks the subcollections too; a plain delete would leave + // every punch, payslip and ledger entry orphaned but still stored. + await db.recursiveDelete(companyRef); + + const { seeded, visitors } = await deleteAllAuthUsers(seed.DEMO_UIDS); + + await seed.seedDemoTenant(); + + return { + projectId, + companyDeleted: true, + seedUsersDeleted: seeded, + visitorUsersDeleted: visitors, + seeded: true, + }; +} diff --git a/backend/functions/src/services/documentWatch.integration.test.ts b/backend/functions/src/services/documentWatch.integration.test.ts new file mode 100644 index 0000000..2a97c9d --- /dev/null +++ b/backend/functions/src/services/documentWatch.integration.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { db, nowTimestamp, tenant } from "../lib/firestore"; +import { runDocumentWatch } from "./documentWatch"; +import { listNotifications } from "./notifications"; + +/** + * The nightly sweep of the document register. + * + * A register nobody opens is not a control. What matters here is who gets told + * and how often — a warning that arrives every night until somebody acts is a + * warning people learn to ignore, and a warning sent to the employee instead + * of to the person who can renew the paper is no warning at all. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); +const TODAY = "2026-09-09"; + +let cid = ""; +let seq = 0; + +async function company(): Promise { + await db.collection("companies").doc(cid).set({ + name: "Docs", + settings: { profile: { currency: "AFN", timezone: "Asia/Kabul" } }, + }); +} + +async function person(id: string, role: string | null): Promise { + await tenant(cid, "employees").doc(id).set({ + firstName: id, + lastName: "T", + status: "ACTIVE", + ...(role ? { role } : {}), + }); +} + +async function paper(employeeId: string, expiresOn: string | null): Promise { + await tenant(cid, "documents").doc(`${employeeId}_${expiresOn ?? "none"}`).set({ + employeeId, + employeeName: employeeId, + type: "CONTRACT", + number: null, + issuedOn: null, + expiresOn, + note: null, + createdBy: "admin", + createdAt: nowTimestamp(), + updatedAt: nowTimestamp(), + }); +} + +beforeEach(async () => { + seq += 1; + cid = `dw_${Date.now()}_${seq}`; + await company(); +}); + +afterEach(async () => { + await db.recursiveDelete(db.collection("companies").doc(cid)); +}); + +describe.skipIf(!EMULATOR)("document expiry watch", () => { + it("tells the people who can actually renew it", async () => { + await person("e_admin", "HR_ADMIN"); + await person("e_worker", "EMPLOYEE"); + await paper("e_worker", "2026-09-20"); + + await runDocumentWatch(TODAY); + + expect(await listNotifications(cid, "e_admin")).toHaveLength(1); + // Not the employee: they cannot renew the company's copy or file it, and a + // notification they can do nothing about is noise. + expect(await listNotifications(cid, "e_worker")).toHaveLength(0); + }); + + it("says nothing when nothing is due", async () => { + await person("e_admin", "HR_ADMIN"); + await paper("e_worker", "2028-01-01"); + + const result = await runDocumentWatch(TODAY); + + expect(result.companiesWarned).toBe(0); + expect(await listNotifications(cid, "e_admin")).toHaveLength(0); + }); + + it("ignores documents that never expire", async () => { + await person("e_admin", "HR_ADMIN"); + await paper("e_worker", null); // a tazkira + + await runDocumentWatch(TODAY); + + expect(await listNotifications(cid, "e_admin")).toHaveLength(0); + }); + + it("counts what expired separately from what is about to", async () => { + // "2 expired" and "2 expiring soon" call for different actions, and a + // single number hides the one that is already a problem. + await person("e_admin", "HR_ADMIN"); + await paper("a", "2026-05-01"); + await paper("b", "2026-06-01"); + await paper("c", "2026-09-20"); + + await runDocumentWatch(TODAY); + + const [note] = await listNotifications(cid, "e_admin"); + expect(String(note.body)).toContain("۲"); // two expired + expect(String(note.body)).toContain("۱"); // one expiring + }); + + // A longer budget on purpose: this runs the whole sweep three times, and the + // sweep walks every company in the project. That is fine nightly and slow in + // a shared emulator that other suites have filled with tenants. + it("does not send the same warning again the same day", { timeout: 30_000 }, async () => { + // Run twice — a retry, a redeploy — and the admin should still have one. + await person("e_admin", "HR_ADMIN"); + await paper("e_worker", "2026-09-20"); + + await runDocumentWatch(TODAY); + await runDocumentWatch(TODAY); + await runDocumentWatch(TODAY); + + expect(await listNotifications(cid, "e_admin")).toHaveLength(1); + }); + + it("warns again on a new day, because it is still not fixed", { timeout: 30_000 }, async () => { + await person("e_admin", "HR_ADMIN"); + await paper("e_worker", "2026-09-20"); + + await runDocumentWatch(TODAY); + await runDocumentWatch("2026-09-10"); + + expect(await listNotifications(cid, "e_admin")).toHaveLength(2); + }); + + it("stays quiet when there is nobody who could act", async () => { + await person("e_worker", "EMPLOYEE"); + await paper("e_worker", "2026-09-20"); + + const result = await runDocumentWatch(TODAY); + + expect(result.companiesWarned).toBe(0); + }); +}); diff --git a/backend/functions/src/services/documentWatch.ts b/backend/functions/src/services/documentWatch.ts new file mode 100644 index 0000000..88d7320 --- /dev/null +++ b/backend/functions/src/services/documentWatch.ts @@ -0,0 +1,83 @@ +import { db } from "../lib/firestore"; +import { localDateOf } from "./attendance"; +import { expiringDocuments } from "./employeeDocuments"; +import { notifyAll } from "./notifications"; +import { getSettings } from "./settings"; +import { tenant } from "../lib/firestore"; + +/** + * The nightly sweep that makes the register worth keeping. + * + * A list of documents nobody opens is not a control — it is another place to + * not look. What turns it into one is somebody being told, unasked, that a + * work permit runs out in three weeks. + * + * Addressed to the people who can act: whoever holds employees:write. Telling + * the employee their own permit is expiring is well meant and useless — they + * cannot renew the company's copy or file it. + */ + +const WARN_WITHIN_DAYS = 30; + +const ADMIN_ROLES = ["COMPANY_ADMIN", "HR_ADMIN", "SUPER_ADMIN"]; + +export interface DocumentWatchResult { + companiesChecked: number; + companiesWarned: number; + documentsFlagged: number; +} + +export async function runDocumentWatch(todayOverride?: string): Promise { + const companies = await db.collection("companies").limit(1000).get(); + let companiesWarned = 0; + let documentsFlagged = 0; + + for (const company of companies.docs) { + const cid = company.id; + try { + const settings = await getSettings(cid); + const today = todayOverride ?? localDateOf(new Date(), settings.profile.timezone); + const due = await expiringDocuments(cid, today, WARN_WITHIN_DAYS); + if (due.length === 0) continue; + + const admins = await adminsOf(cid); + if (admins.length === 0) continue; + + const expired = due.filter((d) => d.standing === "EXPIRED").length; + const soon = due.length - expired; + + await notifyAll(cid, admins, { + kind: "APPROVAL_WAITING", + title: "اسناد کارمندان نیاز به توجه دارد", + body: + expired > 0 + ? `${expired} سند منقضی شده و ${soon} سند تا ${WARN_WITHIN_DAYS} روز دیگر منقضی می‌شود` + : `${soon} سند تا ${WARN_WITHIN_DAYS} روز دیگر منقضی می‌شود`, + link: "/employees", + // One per company per day. Without it a company with an expired + // contract nobody renews gets the same message every night until they + // stop reading any of them. + dedupeKey: `documents_${today}`, + }); + + companiesWarned += 1; + documentsFlagged += due.length; + } catch (error) { + // One company's bad data must not stop the sweep for everybody else. + console.warn("DOCUMENT_WATCH_FAILED", cid, error); + } + } + + return { companiesChecked: companies.size, companiesWarned, documentsFlagged }; +} + +/** Employees whose login carries a role that can actually act on this. */ +async function adminsOf(cid: string): Promise { + const snap = await tenant(cid, "employees").where("status", "==", "ACTIVE").limit(500).get(); + return snap.docs + .filter((d) => { + const role = (d.data() as { role?: string }).role; + return role !== undefined && ADMIN_ROLES.includes(role); + }) + .map((d) => d.id); +} diff --git a/backend/functions/src/services/employeeAccount.test.ts b/backend/functions/src/services/employeeAccount.test.ts new file mode 100644 index 0000000..a223a3b --- /dev/null +++ b/backend/functions/src/services/employeeAccount.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { loginAllowedFor, roleChangeRefusal, claimsFor } from "./employeeAccount"; + +describe("who may still sign in", () => { + it("lets somebody on leave in", () => { + // Still employed: they need their payslip, and they need to extend the + // leave they are already on. + expect(loginAllowedFor("ACTIVE")).toBe(true); + expect(loginAllowedFor("ON_LEAVE")).toBe(true); + }); + + it("shuts the door on somebody suspended or gone", () => { + // This is the whole point: before this, marking somebody EXITED changed a + // chip in a table and they kept every permission they had the day before. + expect(loginAllowedFor("SUSPENDED")).toBe(false); + expect(loginAllowedFor("EXITED")).toBe(false); + }); +}); + +const HR = { actorEmployeeId: "e_hr", actorRoles: ["HR_ADMIN"] }; +const OWNER = { actorEmployeeId: "e_owner", actorRoles: ["COMPANY_ADMIN"] }; + +describe("who may change whose role", () => { + it("allows the ordinary case", () => { + expect( + roleChangeRefusal({ + ...HR, + targetEmployeeId: "e_ali", + targetCurrentRoles: ["EMPLOYEE"], + newRole: "TEAM_LEAD", + }), + ).toBeNull(); + }); + + it("refuses to mint authority that cannot be granted", () => { + // COMPANY_ADMIN is not in ASSIGNABLE_ROLES. Without this an HR admin makes + // themselves the owner in a single call. + expect( + roleChangeRefusal({ + ...HR, + targetEmployeeId: "e_ali", + targetCurrentRoles: ["EMPLOYEE"], + newRole: "COMPANY_ADMIN", + }), + ).toMatch(/cannot be assigned/); + + expect( + roleChangeRefusal({ + ...HR, + targetEmployeeId: "e_ali", + targetCurrentRoles: ["EMPLOYEE"], + newRole: "SUPER_ADMIN", + }), + ).toMatch(/cannot be assigned/); + }); + + it("stops an HR admin taking the company from its owner", () => { + // Every role in this request is assignable — EMPLOYEE is perfectly + // ordinary — so checking the NEW role alone would let this through, and + // afterwards the company has no owner and the HR admin is the most senior + // account left. + expect( + roleChangeRefusal({ + ...HR, + targetEmployeeId: "e_owner", + targetCurrentRoles: ["COMPANY_ADMIN"], + newRole: "EMPLOYEE", + }), + ).toMatch(/company administrator/); + }); + + it("lets an owner change another owner", () => { + expect( + roleChangeRefusal({ + ...OWNER, + targetEmployeeId: "e_other_owner", + targetCurrentRoles: ["COMPANY_ADMIN"], + newRole: "HR_ADMIN", + }), + ).toBeNull(); + }); + + it("refuses anybody changing their own role", () => { + // Self-promotion is already covered, but self-DEMOTION is its own hazard: + // the only COMPANY_ADMIN making themselves an EMPLOYEE locks the company + // out of its own settings with no way back that does not involve us. + expect( + roleChangeRefusal({ + ...OWNER, + targetEmployeeId: "e_owner", + targetCurrentRoles: ["COMPANY_ADMIN"], + newRole: "EMPLOYEE", + }), + ).toMatch(/your own role/); + + expect( + roleChangeRefusal({ + ...HR, + targetEmployeeId: "e_hr", + targetCurrentRoles: ["HR_ADMIN"], + newRole: "EMPLOYEE", + }), + ).toMatch(/your own role/); + }); + + it("checks identity before anything else", () => { + // Somebody editing themselves gets told that, not a confusing message + // about which roles are assignable. + expect( + roleChangeRefusal({ + ...HR, + targetEmployeeId: "e_hr", + targetCurrentRoles: ["HR_ADMIN"], + newRole: "COMPANY_ADMIN", + }), + ).toMatch(/your own role/); + }); +}); + +describe("the claims a login should carry", () => { + it("mirrors the record, branch included", () => { + // The middleware reads r and b from the token and never opens the employee + // document, so a branch written only to Firestore is a branch nobody + // enforces. + expect( + claimsFor({ companyId: "c1", employeeId: "e_1", role: "TEAM_LEAD", branchId: "b_kabul" }), + ).toEqual({ cid: "c1", eid: "e_1", r: ["TEAM_LEAD"], b: ["b_kabul"] }); + }); + + it("carries no branch rather than an empty one", () => { + expect( + claimsFor({ companyId: "c1", employeeId: "e_1", role: "EMPLOYEE", branchId: null }).b, + ).toEqual([]); + }); +}); diff --git a/backend/functions/src/services/employeeAccount.ts b/backend/functions/src/services/employeeAccount.ts new file mode 100644 index 0000000..d1d5aa7 --- /dev/null +++ b/backend/functions/src/services/employeeAccount.ts @@ -0,0 +1,104 @@ +import { ASSIGNABLE_ROLES, type AssignableRole } from "./invite"; + +/** + * The rules governing an employee's LOGIN, as opposed to their record. + * + * The two had drifted apart. Editing an employee wrote Firestore and never + * touched Firebase Auth, so the record and the account it belongs to could + * disagree without anything saying so: a changed email left the person signing + * in with the old one, a changed branch left a manager reading the branch they + * had been moved out of, and marking somebody EXITED changed a chip in a table + * while they kept every permission they had the day before. + * + * These are the rules for closing that gap. They are pure so they can be + * argued with in a test rather than discovered in production. + */ + +export type EmploymentStatus = "ACTIVE" | "ON_LEAVE" | "SUSPENDED" | "EXITED"; + +/** + * Whether somebody with this employment status should still be able to sign in. + * + * ON_LEAVE is still employed: they need their payslip, and they need to file + * the extension of the leave they are already on. SUSPENDED and EXITED are the + * two that mean "this person should not be able to open the app any more" — + * and today both of them still can, which is the point of this module. + */ +export function loginAllowedFor(status: EmploymentStatus): boolean { + return status === "ACTIVE" || status === "ON_LEAVE"; +} + +function isAssignable(role: string): role is AssignableRole { + return (ASSIGNABLE_ROLES as readonly string[]).includes(role); +} + +/** Roles that hold the company itself, and are never granted through the API. */ +function isOwnerLevel(roles: readonly string[]): boolean { + return roles.some((r) => r === "COMPANY_ADMIN" || r === "SUPER_ADMIN"); +} + +/** + * Why a role change must be refused, or null when it may proceed. + * + * Three things are being defended against, and only the first is obvious: + * + * 1. Minting authority that cannot be granted. COMPANY_ADMIN and SUPER_ADMIN + * are not in ASSIGNABLE_ROLES, so no request may produce one. Without + * this an HR admin could make themselves the owner in one call. + * + * 2. Taking the company from its owner. ASSIGNABLE_ROLES alone does not stop + * an HR admin from DEMOTING the COMPANY_ADMIN to EMPLOYEE — every role in + * that request is assignable, and afterwards the company has no owner and + * the HR admin is the most senior account left. So an actor may only + * change somebody whose current authority they could have granted. + * + * 3. Changing your own. Self-promotion is covered by (1), but self-demotion + * is its own hazard: the only COMPANY_ADMIN making themselves an EMPLOYEE + * locks the company out of its own settings with no way back that does not + * involve us. Roles are changed by somebody else, always. + */ +export function roleChangeRefusal(params: { + actorEmployeeId: string; + actorRoles: readonly string[]; + targetEmployeeId: string; + targetCurrentRoles: readonly string[]; + newRole: string; +}): string | null { + const { actorEmployeeId, actorRoles, targetEmployeeId, targetCurrentRoles, newRole } = params; + + if (actorEmployeeId === targetEmployeeId) { + return "You cannot change your own role; ask another administrator"; + } + + if (!isAssignable(newRole)) { + return `Role "${newRole}" cannot be assigned through the portal`; + } + + if (isOwnerLevel(targetCurrentRoles) && !isOwnerLevel(actorRoles)) { + return "Only a company administrator can change a company administrator's role"; + } + + return null; +} + +/** + * The claims an employee's login should carry, given the record as it now is. + * + * Kept next to the rules above because the claims ARE the authorisation: the + * middleware reads `r` and `b` from the token and never opens the employee + * document. A branch written to Firestore and not to the claims is a branch + * the server does not enforce. + */ +export function claimsFor(params: { + companyId: string; + employeeId: string; + role: string; + branchId: string | null; +}): { cid: string; eid: string; r: string[]; b: string[] } { + return { + cid: params.companyId, + eid: params.employeeId, + r: [params.role], + b: params.branchId ? [params.branchId] : [], + }; +} diff --git a/backend/functions/src/services/employeeDocuments.test.ts b/backend/functions/src/services/employeeDocuments.test.ts new file mode 100644 index 0000000..e66fd90 --- /dev/null +++ b/backend/functions/src/services/employeeDocuments.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { daysBetween, standingOf } from "./employeeDocuments"; + +/** + * How a document stands on a given day. + * + * The expensive case this exists for is not a document that is missing — it is + * one that quietly ran out four months ago, which means somebody has been + * working without a valid contract and the company cannot answer for it. + */ + +const TODAY = "2026-09-09"; + +describe("standing", () => { + it("is valid while there is time", () => { + const s = standingOf("2027-01-01", TODAY); + expect(s.standing).toBe("VALID"); + expect(s.daysLeft).toBe(114); + }); + + it("warns inside the window", () => { + expect(standingOf("2026-10-01", TODAY).standing).toBe("EXPIRING"); + expect(standingOf("2026-09-10", TODAY).standing).toBe("EXPIRING"); + }); + + it("treats today as still expiring, not yet expired", () => { + // A contract is valid ON the day it expires. Calling it expired sends + // somebody home a day early. + const s = standingOf(TODAY, TODAY); + expect(s.standing).toBe("EXPIRING"); + expect(s.daysLeft).toBe(0); + }); + + it("counts an expired one in negative days, so a list can sort by urgency", () => { + const s = standingOf("2026-05-01", TODAY); + expect(s.standing).toBe("EXPIRED"); + expect(s.daysLeft).toBeLessThan(0); + }); + + it("says a tazkira does not expire rather than pretending it is valid", () => { + // NO_EXPIRY and VALID are different facts. A register that shows every + // permanent document as "valid, 0 days" teaches people to ignore the + // column. + expect(standingOf(null, TODAY).standing).toBe("NO_EXPIRY"); + expect(standingOf(undefined, TODAY).daysLeft).toBeNull(); + }); + + it("respects a different warning window", () => { + expect(standingOf("2026-11-01", TODAY, 30).standing).toBe("VALID"); + expect(standingOf("2026-11-01", TODAY, 90).standing).toBe("EXPIRING"); + }); + + it("does not fall over on a date nobody can parse", () => { + expect(standingOf("not-a-date", TODAY).standing).toBe("EXPIRING"); + expect(daysBetween("x", TODAY)).toBe(0); + }); +}); + +describe("counting days", () => { + it("counts whole days each way", () => { + expect(daysBetween("2026-09-09", "2026-09-10")).toBe(1); + expect(daysBetween("2026-09-09", "2026-09-08")).toBe(-1); + expect(daysBetween("2026-09-09", "2026-09-09")).toBe(0); + }); + + it("crosses a month and a year without drifting", () => { + expect(daysBetween("2026-12-25", "2027-01-01")).toBe(7); + // 2028 is a leap year: February has 29 days. + expect(daysBetween("2028-02-01", "2028-03-01")).toBe(29); + }); +}); diff --git a/backend/functions/src/services/employeeDocuments.ts b/backend/functions/src/services/employeeDocuments.ts new file mode 100644 index 0000000..fcf2c2f --- /dev/null +++ b/backend/functions/src/services/employeeDocuments.ts @@ -0,0 +1,170 @@ +import { ApiError } from "../lib/errors"; +import { nowTimestamp, tenant } from "../lib/firestore"; +import { ulid } from "../lib/ids"; + +/** + * The papers a company has to hold for each person, and when they run out. + * + * A tazkira, an employment contract, a work permit, a health certificate. A + * firm working for an NGO or a ministry is required to keep these, and the + * expensive part is not storing them — it is the day somebody's contract + * expired four months ago and nobody noticed, which means a person has been + * working without one and the company cannot answer for it. + * + * So this is deliberately a REGISTER, not a filing cabinet: what the document + * is, its number, and the date it stops being valid. The scan itself needs + * Cloud Storage and a set of access rules that deserve their own decision, and + * the expiry warning is worth having long before the photograph is. + */ + +export const DOCUMENT_TYPES = [ + "TAZKIRA", + "CONTRACT", + "WORK_PERMIT", + "HEALTH_CERTIFICATE", + "LICENCE", + "OTHER", +] as const; + +export type DocumentType = (typeof DOCUMENT_TYPES)[number]; + +export interface EmployeeDocumentDoc { + employeeId: string; + employeeName: string; + type: DocumentType; + /** The number on the paper. Not unique, not validated — it is a label. */ + number: string | null; + issuedOn: string | null; + /** Null for a document that does not expire, such as a tazkira. */ + expiresOn: string | null; + note: string | null; + createdBy: string; + createdAt: FirebaseFirestore.Timestamp; + updatedAt: FirebaseFirestore.Timestamp; +} + +export function documentToDto(id: string, doc: EmployeeDocumentDoc): Record { + return { + id, + employeeId: doc.employeeId, + employeeName: doc.employeeName, + type: doc.type, + number: doc.number, + issuedOn: doc.issuedOn, + expiresOn: doc.expiresOn, + note: doc.note, + }; +} + +/** + * How a document stands on a given day. + * + * Pure, and separate from the storage, because "how many days until this + * matters" is the only interesting thing about a document and it should be + * arguable in a test. + * + * Both dates are plain `YYYY-MM-DD` and compared as strings, which is correct + * for ISO dates and avoids inventing a timezone for a piece of paper: a + * contract does not expire at midnight in Kabul, it expires on a date. + */ +export type DocumentStanding = "VALID" | "EXPIRING" | "EXPIRED" | "NO_EXPIRY"; + +export function standingOf( + expiresOn: string | null | undefined, + todayIso: string, + warnWithinDays = 30, +): { standing: DocumentStanding; daysLeft: number | null } { + if (!expiresOn) return { standing: "NO_EXPIRY", daysLeft: null }; + + const days = daysBetween(todayIso, expiresOn); + if (days < 0) return { standing: "EXPIRED", daysLeft: days }; + if (days <= warnWithinDays) return { standing: "EXPIRING", daysLeft: days }; + return { standing: "VALID", daysLeft: days }; +} + +/** Whole days from `fromIso` to `toIso`; negative when `toIso` is in the past. */ +export function daysBetween(fromIso: string, toIso: string): number { + const from = Date.parse(`${fromIso}T00:00:00Z`); + const to = Date.parse(`${toIso}T00:00:00Z`); + if (Number.isNaN(from) || Number.isNaN(to)) return 0; + return Math.round((to - from) / 86_400_000); +} + +export async function addDocument( + cid: string, + input: { + employeeId: string; + type: DocumentType; + number: string | null; + issuedOn: string | null; + expiresOn: string | null; + note: string | null; + }, + createdBy: string, +): Promise<{ id: string; doc: EmployeeDocumentDoc }> { + const employee = await tenant(cid, "employees").doc(input.employeeId).get(); + if (!employee.exists) throw ApiError.notFound("Employee not found"); + const e = employee.data() as { firstName?: string; lastName?: string }; + + const now = nowTimestamp(); + const doc: EmployeeDocumentDoc = { + employeeId: input.employeeId, + employeeName: `${e.firstName ?? ""} ${e.lastName ?? ""}`.trim(), + type: input.type, + number: input.number, + issuedOn: input.issuedOn, + expiresOn: input.expiresOn, + note: input.note, + createdBy, + createdAt: now, + updatedAt: now, + }; + const id = ulid(); + await tenant(cid, "documents").doc(id).create(doc); + return { id, doc }; +} + +export async function deleteDocument(cid: string, id: string): Promise { + const ref = tenant(cid, "documents").doc(id); + if (!(await ref.get()).exists) throw ApiError.notFound("Document not found"); + await ref.delete(); +} + +export async function listDocuments( + cid: string, + employeeId: string | null, +): Promise[]> { + const base = tenant(cid, "documents"); + const snap = await (employeeId + ? base.where("employeeId", "==", employeeId).limit(300).get() + : base.limit(500).get()); + + return snap.docs + .map((d) => documentToDto(d.id, d.data() as EmployeeDocumentDoc)) + .sort((a, b) => String(a.expiresOn ?? "9999").localeCompare(String(b.expiresOn ?? "9999"))); +} + +/** + * Documents that have run out, or are about to. + * + * Ordered by how urgent they are — the ones already expired first — because a + * list that buries an expired work permit under thirty upcoming renewals is a + * list nobody acts on. + */ +export async function expiringDocuments( + cid: string, + todayIso: string, + warnWithinDays = 30, +): Promise< + (Record & { standing: DocumentStanding; daysLeft: number | null })[] +> { + const snap = await tenant(cid, "documents").limit(1000).get(); + + return snap.docs + .map((d) => { + const doc = d.data() as EmployeeDocumentDoc; + return { ...documentToDto(d.id, doc), ...standingOf(doc.expiresOn, todayIso, warnWithinDays) }; + }) + .filter((d) => d.standing === "EXPIRED" || d.standing === "EXPIRING") + .sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0)); +} diff --git a/backend/functions/src/services/employeeSync.ts b/backend/functions/src/services/employeeSync.ts new file mode 100644 index 0000000..49e2437 --- /dev/null +++ b/backend/functions/src/services/employeeSync.ts @@ -0,0 +1,99 @@ +import { getAuth } from "firebase-admin/auth"; +import { ApiError, ErrorCodes } from "../lib/errors"; +import { claimsFor, loginAllowedFor, type EmploymentStatus } from "./employeeAccount"; + +/** + * Pushes an employee record onto the login it belongs to. + * + * uid == employeeId (see createEmployeeLogin), so there is exactly one account + * per employee and no lookup is needed. + * + * Everything here exists because editing an employee used to write Firestore + * and stop. The record and the account then disagreed silently, in four ways + * that all look fine on screen: + * + * - a changed email left the person signing in with the old one + * - a changed name left the old one on the account + * - a changed branch left the claims — which are what the server actually + * enforces — pointing at the branch they had been moved out of + * - EXITED changed a chip in a table while every permission stayed live + */ +export async function syncEmployeeLogin(params: { + companyId: string; + employeeId: string; + email: string; + displayName: string; + role: string; + branchId: string | null; + status: EmploymentStatus; +}): Promise<{ changed: string[] }> { + const auth = getAuth(); + + // An employee created with createLogin: false has no account at all. That is + // legitimate — a company can hold records for people who never touch the + // app — so there is nothing to sync rather than something to fail on. + const user = await auth.getUser(params.employeeId).catch(() => null); + if (!user) return { changed: [] }; + + const changed: string[] = []; + const update: { email?: string; displayName?: string; disabled?: boolean } = {}; + + if (params.email && params.email !== user.email) { + update.email = params.email; + changed.push("email"); + } + if (params.displayName && params.displayName !== user.displayName) { + update.displayName = params.displayName; + changed.push("name"); + } + + const shouldBeDisabled = !loginAllowedFor(params.status); + if (shouldBeDisabled !== user.disabled) { + update.disabled = shouldBeDisabled; + changed.push(shouldBeDisabled ? "access revoked" : "access restored"); + } + + if (Object.keys(update).length > 0) { + try { + await auth.updateUser(params.employeeId, update); + } catch (error) { + // The one failure a manager can actually cause and act on. Anything else + // is ours and should surface as itself. + if ((error as { code?: string }).code === "auth/email-already-exists") { + throw new ApiError( + 409, + ErrorCodes.CONFLICT, + "Another account already uses this email address", + ); + } + throw error; + } + } + + const wanted = claimsFor(params); + const current = (user.customClaims ?? {}) as Record; + const sameRole = + Array.isArray(current.r) && current.r.length === 1 && current.r[0] === params.role; + const sameBranch = JSON.stringify(current.b ?? []) === JSON.stringify(wanted.b); + if (!sameRole || !sameBranch) { + await auth.setCustomUserClaims(params.employeeId, wanted); + if (!sameRole) changed.push("role"); + if (!sameBranch) changed.push("branch"); + } + + if (shouldBeDisabled || !sameRole || !sameBranch) { + // Claims and the disabled flag both live in the token, and a token already + // in a phone's memory keeps its old contents until it expires — up to an + // hour. Revoking the refresh token is what bounds that: the app cannot + // renew, so the old authority dies with the current token rather than + // being quietly renewed all afternoon. + // + // It does NOT make the change instant. Somebody dismissed at 09:00 may + // still punch at 09:40. Closing that window entirely means reading the + // employee record on every single request, which is a cost worth deciding + // on deliberately rather than smuggling in here. + await auth.revokeRefreshTokens(params.employeeId); + } + + return { changed }; +} diff --git a/backend/functions/src/services/employees.test.ts b/backend/functions/src/services/employees.test.ts new file mode 100644 index 0000000..1971b47 --- /dev/null +++ b/backend/functions/src/services/employees.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { nextEmployeeCode } from "./employees"; + +describe("nextEmployeeCode", () => { + it("starts a brand-new company at E-001", () => { + // Not reachable through the product — signup always writes E-001 for the + // founding admin — but the function must not depend on that being true. + expect(nextEmployeeCode([])).toBe("E-001"); + }); + + it("follows the code signup already wrote", () => { + // The first employee an administrator adds after signing up. + expect(nextEmployeeCode(["E-001"])).toBe("E-002"); + }); + + it("continues from the highest, not from the count", () => { + // Somebody was deleted, so there are 3 employees and the next free number + // is 9. Counting them would hand out E-004 and duplicate an existing code. + expect(nextEmployeeCode(["E-001", "E-005", "E-008"])).toBe("E-009"); + }); + + it("compares numerically, not as text", () => { + // "E-9" > "E-10" as strings, so a max-by-string would go back to E-010 + // and collide. + expect(nextEmployeeCode(["E-009", "E-010", "E-011"])).toBe("E-012"); + }); + + it("ignores codes it does not own", () => { + // A company that imported its own payroll numbers. Those are theirs; the + // generator numbers its own scheme and leaves the rest alone. + expect(nextEmployeeCode(["1042", "ACC-7", "", null, undefined])).toBe("E-001"); + }); + + it("does not mistake a code that merely contains one", () => { + expect(nextEmployeeCode(["BR-E-004", "E-004-TEMP", "e-004"])).toBe("E-001"); + }); + + it("tolerates whitespace around a hand-typed code", () => { + expect(nextEmployeeCode([" E-007 "])).toBe("E-008"); + }); + + it("keeps padding to three, and stops growing rather than renumbering", () => { + // Alignment past 999 is worth less than never changing a code somebody has + // already written on a personnel file. + expect(nextEmployeeCode(["E-098"])).toBe("E-099"); + expect(nextEmployeeCode(["E-099"])).toBe("E-100"); + expect(nextEmployeeCode(["E-999"])).toBe("E-1000"); + expect(nextEmployeeCode(["E-1000"])).toBe("E-1001"); + }); + + it("survives a number no integer can hold", () => { + // A hand-typed code of forty digits parses to Infinity; adding one to that + // would hand out "E-Infinity" to a real person. + expect(nextEmployeeCode(["E-" + "9".repeat(40), "E-003"])).toBe("E-004"); + }); +}); diff --git a/backend/functions/src/services/employees.ts b/backend/functions/src/services/employees.ts new file mode 100644 index 0000000..d58c9e5 --- /dev/null +++ b/backend/functions/src/services/employees.ts @@ -0,0 +1,43 @@ +/** + * Employee numbering. + * + * The product already has a convention: signup gives the founding administrator + * E-001, and the demo tenant runs E-001 to E-008. Until now every subsequent + * code was typed by hand, which is work that belongs to the machine and gets + * done badly by people — the same number twice, a skipped digit, or "5" and + * "E-005" in the same company. + */ + +/** Codes this generator owns. Anything else in a tenant is left alone. */ +const GENERATED = /^E-(\d+)$/; + +/** + * The next free code for a company, given every code it already uses. + * + * It reads the existing codes instead of keeping a counter. A counter is + * cheaper and wrong here: a company that imports staff from its old payroll + * system, or an administrator who types a code by hand, would put a number in + * the collection the counter has never heard of, and the next generated code + * would collide with it — silently, because nothing enforces uniqueness on a + * display code. + * + * Codes that are not `E-` are ignored rather than rejected. A tenant is + * free to number its people however it likes, and this only has to find the + * next free code in the scheme it owns. + * + * Padded to three digits because the numbers are read side by side in a list, + * where E-9 next to E-10 reads as a mistake. Past 999 the padding stops + * growing rather than renumbering anyone: E-1000 follows E-999, still unique, + * still parsed by this function on the next call. Alignment is worth less than + * never changing a code somebody has already written on a file. + */ +export function nextEmployeeCode(existing: Iterable): string { + let highest = 0; + for (const code of existing) { + const match = GENERATED.exec((code ?? "").trim()); + if (!match) continue; + const value = Number(match[1]); + if (Number.isSafeInteger(value) && value > highest) highest = value; + } + return `E-${String(highest + 1).padStart(3, "0")}`; +} diff --git a/backend/functions/src/services/expenses.integration.test.ts b/backend/functions/src/services/expenses.integration.test.ts new file mode 100644 index 0000000..f23bb34 --- /dev/null +++ b/backend/functions/src/services/expenses.integration.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { db, tenant } from "../lib/firestore"; +import { createExpense, decideExpense } from "./expenses"; + +/** + * Approving or paying an expense used to be a read-modify-write across separate + * round trips, with the ledger entry posted afterwards under a random id. Two + * approvers could both pass the status guard and both post, double-relieving + * Accounts Payable; and a failure between the two writes left an expense marked + * decided with nothing in the ledger and no way to retry, because the state + * machine rejects a second attempt. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); +let cid = ""; +let seq = 0; + +async function draft(amount = 5000): Promise { + const expense = await createExpense( + cid, + { + category: "services", + vendor: "Kabul Supplies", + description: "Monthly service", + amount, + currency: "AFN", + date: "2026-08-01", + }, + "admin", + ); + return expense.id; +} + +async function entries(): Promise[]> { + const snap = await tenant(cid, "journalEntries").where("source", "==", "EXPENSE").get(); + return snap.docs.map((d) => d.data() as Record); +} + +describe.skipIf(!EMULATOR)("expense decisions", () => { + beforeEach(async () => { + cid = `exp_${Date.now()}_${seq++}`; + await db.collection("companies").doc(cid).set({ + name: "Expenses", + timezone: "Asia/Kabul", + settings: { profile: { currency: "AFN", timezone: "Asia/Kabul" } }, + }); + }); + + it("posts one ledger entry when an expense is approved", async () => { + const id = await draft(); + + const result = await decideExpense(cid, id, "APPROVE", "admin"); + + expect(result.status).toBe("APPROVED"); + expect(await entries()).toHaveLength(1); + }); + + it("settles Accounts Payable exactly once when two approvers pay at the same time", async () => { + const id = await draft(); + await decideExpense(cid, id, "APPROVE", "admin"); + + const [a, b] = await Promise.allSettled([ + decideExpense(cid, id, "PAY", "finance-1"), + decideExpense(cid, id, "PAY", "finance-2"), + ]); + + // One wins; the other loses the race and is refused by the state machine. + const settled = [a, b].filter((r) => r.status === "fulfilled"); + expect(settled).toHaveLength(1); + + // One approval entry + one payment entry. Two payments would relieve the + // payable twice and credit Bank twice for money that left once. + const posted = await entries(); + expect(posted).toHaveLength(2); + const payments = posted.filter((e) => String(e.memo).startsWith("Payment:")); + expect(payments).toHaveLength(1); + }); + + it("leaves the expense undecided when its ledger entry cannot be posted", async () => { + // A zero-amount entry is rejected by the journal. The status write and the + // ledger write are one transaction, so neither lands. + const id = await draft(0); + + await expect(decideExpense(cid, id, "APPROVE", "admin")).rejects.toThrow(); + + const after = await tenant(cid, "expenses").doc(id).get(); + expect(after.data()?.status).toBe("DRAFT"); + expect(await entries()).toHaveLength(0); + }); + + it("refuses to pay an expense that was never approved", async () => { + const id = await draft(); + + await expect(decideExpense(cid, id, "PAY", "admin")).rejects.toThrow(); + expect(await entries()).toHaveLength(0); + }); +}); diff --git a/backend/functions/src/services/expenses.ts b/backend/functions/src/services/expenses.ts new file mode 100644 index 0000000..bfa1239 --- /dev/null +++ b/backend/functions/src/services/expenses.ts @@ -0,0 +1,221 @@ +import { Timestamp } from "firebase-admin/firestore"; +import { ApiError } from "../lib/errors"; +import { db, nowTimestamp, tenant, toIso } from "../lib/firestore"; +import { ulid } from "../lib/ids"; +import { buildJournalEntry } from "./accounting"; + +/** + * Company expenses & vendor bills. Lifecycle: DRAFT → APPROVED → PAID, or + * DRAFT → REJECTED. Approving posts a journal entry (Dr expense / Cr Accounts + * Payable); paying posts the settlement (Dr Accounts Payable / Cr Cash), so the + * general ledger and finance reports stay in sync automatically. + */ + +export type ExpenseStatus = "DRAFT" | "APPROVED" | "REJECTED" | "PAID"; + +export type ExpenseCategory = + | "rent" + | "utilities" + | "supplies" + | "travel" + | "services" + | "other"; + +/** Expense category → chart-of-accounts expense code. */ +const CATEGORY_ACCOUNT: Record = { + rent: "5100", + utilities: "5200", + supplies: "5300", + travel: "5400", + services: "5900", + other: "5900", +}; + +const CATEGORY_NAME: Record = { + rent: "Rent", + utilities: "Utilities", + supplies: "Office Supplies", + travel: "Travel & Transport", + services: "Other Expenses", + other: "Other Expenses", +}; + +const PAYABLE_CODE = "2000"; +const PAYABLE_NAME = "Accounts Payable"; +const CASH_CODE = "1010"; +const CASH_NAME = "Bank"; + +export interface ExpenseDto { + id: string; + category: ExpenseCategory; + vendor: string; + description: string; + amount: number; + currency: string; + date: string; + status: ExpenseStatus; + accountCode: string; + createdBy: string; + createdAt: string | null; + decidedBy: string | null; + decidedAt: string | null; +} + +function toDto(id: string, v: Record): ExpenseDto { + return { + id, + category: (v.category as ExpenseCategory) ?? "other", + vendor: (v.vendor as string) ?? "", + description: (v.description as string) ?? "", + amount: (v.amount as number) ?? 0, + currency: (v.currency as string) ?? "AFN", + date: (v.date as string) ?? "", + status: (v.status as ExpenseStatus) ?? "DRAFT", + accountCode: (v.accountCode as string) ?? "5900", + createdBy: (v.createdBy as string) ?? "", + createdAt: toIso((v.createdAt as Timestamp | null | undefined) ?? null), + decidedBy: (v.decidedBy as string | null) ?? null, + decidedAt: toIso((v.decidedAt as Timestamp | null | undefined) ?? null), + }; +} + +export async function listExpenses(cid: string, status?: string): Promise { + const snap = await tenant(cid, "expenses").get(); + return snap.docs + .map((d) => toDto(d.id, d.data() as Record)) + .filter((e) => !status || e.status === status) + .sort((a, b) => b.date.localeCompare(a.date) || (b.createdAt ?? "").localeCompare(a.createdAt ?? "")); +} + +export async function createExpense( + cid: string, + input: { + category: ExpenseCategory; + vendor: string; + description: string; + amount: number; + currency: string; + date: string; + }, + actorId: string, +): Promise { + const id = ulid(); + const doc = { + companyId: cid, + category: input.category, + vendor: input.vendor, + description: input.description, + amount: Math.round(input.amount * 100) / 100, + currency: input.currency, + date: input.date, + status: "DRAFT" as ExpenseStatus, + accountCode: CATEGORY_ACCOUNT[input.category], + createdBy: actorId, + createdAt: nowTimestamp(), + decidedBy: null, + decidedAt: null, + }; + await tenant(cid, "expenses").doc(id).set(doc); + return toDto(id, doc); +} + +/** + * Advances an expense through its lifecycle and posts the matching ledger entry. + * APPROVE (from DRAFT) → Dr expense / Cr payable. PAY (from APPROVED) → Dr + * payable / Cr cash. REJECT (from DRAFT) → no ledger impact. + */ +export async function decideExpense( + cid: string, + id: string, + action: "APPROVE" | "REJECT" | "PAY", + actorId: string, +): Promise { + const ref = tenant(cid, "expenses").doc(id); + const journal = tenant(cid, "journalEntries"); + + // The status guard, the status write and the ledger entry commit together. + // As separate round trips, two approvers could both read DRAFT, both pass the + // guard and both post the entry — double-relieving Accounts Payable. And + // because the status landed before the ledger write, a failure in between + // left an expense marked APPROVED with nothing in the ledger and no way to + // retry, since the state machine rejects a second attempt. + return db.runTransaction(async (tx) => { + const snap = await tx.get(ref); + if (!snap.exists) throw ApiError.notFound("Expense not found"); + const expense = toDto(id, snap.data() as Record); + + const valid: Record = { APPROVE: "DRAFT", REJECT: "DRAFT", PAY: "APPROVED" }; + if (expense.status !== valid[action]) { + throw ApiError.business( + "INVALID_STATE", + `Cannot ${action.toLowerCase()} an expense in status ${expense.status}`, + ); + } + + const nextStatus: ExpenseStatus = + action === "APPROVE" ? "APPROVED" : action === "PAY" ? "PAID" : "REJECTED"; + + tx.update(ref, { + status: nextStatus, + decidedBy: actorId, + decidedAt: nowTimestamp(), + }); + + // Ids are derived from the expense and the step, so each of APPROVE and PAY + // owns exactly one entry: a replay overwrites its own rather than minting a + // second one that double-counts. + if (action === "APPROVE") { + const { id: entryId, doc } = buildJournalEntry({ + date: expense.date, + memo: `Expense: ${expense.vendor} — ${CATEGORY_NAME[expense.category]}`, + reference: id, + source: "EXPENSE", + entryId: `EXPENSE_${id}_APPROVE`, + createdBy: actorId, + lines: [ + { + accountCode: expense.accountCode, + accountName: CATEGORY_NAME[expense.category], + debit: expense.amount, + credit: 0, + }, + { accountCode: PAYABLE_CODE, accountName: PAYABLE_NAME, debit: 0, credit: expense.amount }, + ], + }); + tx.set(journal.doc(entryId), doc); + } else if (action === "PAY") { + const { id: entryId, doc } = buildJournalEntry({ + date: expense.date, + memo: `Payment: ${expense.vendor}`, + reference: id, + source: "EXPENSE", + entryId: `EXPENSE_${id}_PAY`, + createdBy: actorId, + lines: [ + { accountCode: PAYABLE_CODE, accountName: PAYABLE_NAME, debit: expense.amount, credit: 0 }, + { accountCode: CASH_CODE, accountName: CASH_NAME, debit: 0, credit: expense.amount }, + ], + }); + tx.set(journal.doc(entryId), doc); + } + + return { ...expense, status: nextStatus, decidedBy: actorId }; + }); +} + +/** Approved + paid expense total, and count of drafts awaiting a decision. */ +export async function expensesSummary( + cid: string, +): Promise<{ count: number; pendingCount: number; approvedTotal: number }> { + const all = await listExpenses(cid); + return { + count: all.length, + pendingCount: all.filter((e) => e.status === "DRAFT").length, + approvedTotal: + Math.round( + all + .filter((e) => e.status === "APPROVED" || e.status === "PAID") + .reduce((s, e) => s + e.amount, 0) * 100, + ) / 100, + }; +} diff --git a/backend/functions/src/services/face.ts b/backend/functions/src/services/face.ts new file mode 100644 index 0000000..369eca0 --- /dev/null +++ b/backend/functions/src/services/face.ts @@ -0,0 +1,93 @@ +import { FieldValue } from "firebase-admin/firestore"; +import { ApiError, ErrorCodes } from "../lib/errors"; +import { audit, nowTimestamp, tenant } from "../lib/firestore"; +import { compareEmbeddings, FACE_MATCH_THRESHOLD, type FaceMatch } from "../lib/face-math"; + +/** + * On-device face recognition support. + * + * The Android app computes a face embedding locally with an on-device model + * (MobileFaceNet, 192-d) and sends ONLY the numeric vector — never the photo — + * for enrollment and verification. Identity matching is cosine similarity + * between the check-in embedding and the employee's enrolled embedding, so no + * biometric image ever leaves the device or is stored on the server. + * + * Pure math and the request schema live in ../lib/face-math (unit-tested). + */ + +export { embeddingSchema, FACE_MATCH_THRESHOLD, type FaceMatch } from "../lib/face-math"; + +/** + * Enrolls an employee's face. + * + * Enrollment is once-only on purpose: silently overwriting an existing + * embedding would let anyone holding an unlocked phone re-enroll their own + * face onto the account and check in as its owner indefinitely. Replacing an + * enrollment requires an admin reset (DELETE /employees/:id/face), which is + * permission-checked and audited. + */ +export async function enrollFace( + cid: string, + employeeId: string, + embedding: number[], +): Promise { + const ref = tenant(cid, "employees").doc(employeeId); + const snap = await ref.get(); + if (!snap.exists) { + throw ApiError.notFound("Employee not found"); + } + if (Array.isArray((snap.data() as { faceEmbedding?: unknown }).faceEmbedding)) { + throw ApiError.business( + ErrorCodes.FACE_ALREADY_ENROLLED, + "A face is already enrolled; ask an administrator to reset it first", + ); + } + + await ref.update({ + faceEmbedding: embedding, + faceEnrolledAt: nowTimestamp(), + }); + + // Identity-changing operation: always audited (never log the embedding). + await audit(cid, { + actorId: employeeId, + actorRole: "EMPLOYEE", + action: "employees.face.enroll", + resourceType: "employees", + resourceId: employeeId, + after: { faceEnrolled: true, dimensions: embedding.length }, + }); +} + +export async function clearFace(cid: string, employeeId: string): Promise { + await tenant(cid, "employees").doc(employeeId).update({ + faceEmbedding: FieldValue.delete(), + faceEnrolledAt: FieldValue.delete(), + }); +} + +export async function getEnrolledEmbedding( + cid: string, + employeeId: string, +): Promise { + const snap = await tenant(cid, "employees").doc(employeeId).get(); + const v = (snap.data() as { faceEmbedding?: unknown } | undefined)?.faceEmbedding; + return Array.isArray(v) && v.every((n) => typeof n === "number") ? (v as number[]) : null; +} + +export interface FaceVerifyResult extends FaceMatch { + enrolled: boolean; +} + +/** Compares a candidate embedding against the employee's enrolled one. */ +export async function verifyFace( + cid: string, + employeeId: string, + candidate: number[], +): Promise { + const stored = await getEnrolledEmbedding(cid, employeeId); + if (!stored) { + return { match: false, similarity: 0, threshold: FACE_MATCH_THRESHOLD, enrolled: false }; + } + return { ...compareEmbeddings(stored, candidate), enrolled: true }; +} diff --git a/backend/functions/src/services/finance-reports.ts b/backend/functions/src/services/finance-reports.ts new file mode 100644 index 0000000..5642f22 --- /dev/null +++ b/backend/functions/src/services/finance-reports.ts @@ -0,0 +1,105 @@ +import { tenant } from "../lib/firestore"; +import { listAccounts, type AccountType, type JournalLine } from "./accounting"; +import { expensesSummary } from "./expenses"; + +/** + * Finance overview report: ledger position (income/expense/net profit by + * account type), an operational expenses/payroll summary, and a 6-month + * income-vs-expense trend derived from journal entry dates. + */ + +export interface MonthlyPoint { + month: string; // ISO YYYY-MM (Gregorian, from entry date) + income: number; + expense: number; + net: number; +} + +export interface FinanceOverview { + currency: string; + ledger: { + incomeTotal: number; + expenseTotal: number; + assetTotal: number; + liabilityTotal: number; + netProfit: number; + }; + expenses: { count: number; pendingCount: number; approvedTotal: number }; + payroll: { runCount: number; netTotal: number }; + trend: MonthlyPoint[]; +} + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +export async function financeOverview(cid: string, currency: string): Promise { + const [accounts, journalSnap, payrollSnap, expSummary] = await Promise.all([ + listAccounts(cid), + tenant(cid, "journalEntries").get(), + tenant(cid, "payrollRuns").get(), + expensesSummary(cid), + ]); + + const typeByCode = new Map(accounts.map((a) => [a.code, a.type])); + + const byType: Record = { + ASSET: 0, + LIABILITY: 0, + EQUITY: 0, + INCOME: 0, + EXPENSE: 0, + }; + const months = new Map(); + + for (const doc of journalSnap.docs) { + const data = doc.data() as { date?: string; lines?: JournalLine[] }; + const month = (data.date ?? "").slice(0, 7); // YYYY-MM + for (const l of data.lines ?? []) { + const type = typeByCode.get(l.accountCode); + if (!type) continue; + const debit = l.debit || 0; + const credit = l.credit || 0; + // Signed balance in the account's normal direction. + const signed = + type === "ASSET" || type === "EXPENSE" ? debit - credit : credit - debit; + byType[type] = round2(byType[type] + signed); + + if (month && (type === "INCOME" || type === "EXPENSE")) { + const cur = months.get(month) ?? { income: 0, expense: 0 }; + if (type === "INCOME") cur.income += credit - debit; + else cur.expense += debit - credit; + months.set(month, cur); + } + } + } + + const payrollNet = payrollSnap.docs.reduce( + (s, d) => s + ((d.data().totalNet as number) ?? 0), + 0, + ); + + const trend: MonthlyPoint[] = [...months.entries()] + .map(([month, v]) => ({ + month, + income: round2(v.income), + expense: round2(v.expense), + net: round2(v.income - v.expense), + })) + .sort((a, b) => a.month.localeCompare(b.month)) + .slice(-6); + + return { + currency, + ledger: { + incomeTotal: round2(byType.INCOME), + expenseTotal: round2(byType.EXPENSE), + assetTotal: round2(byType.ASSET), + liabilityTotal: round2(byType.LIABILITY), + netProfit: round2(byType.INCOME - byType.EXPENSE), + }, + expenses: expSummary, + payroll: { runCount: payrollSnap.size, netTotal: round2(payrollNet) }, + trend, + }; +} diff --git a/backend/functions/src/services/geo.ts b/backend/functions/src/services/geo.ts new file mode 100644 index 0000000..a0afadb --- /dev/null +++ b/backend/functions/src/services/geo.ts @@ -0,0 +1,75 @@ +import { tenant } from "../lib/firestore"; + +const EARTH_RADIUS_METERS = 6_371_000; + +export function haversineMeters( + lat1: number, + lng1: number, + lat2: number, + lng2: number, +): number { + const toRad = (deg: number): number => (deg * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; + return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +export interface GeofenceCheck { + fencesConfigured: boolean; + insideFence: boolean; + geofenceId: string | null; + distanceMeters: number | null; +} + +/** + * Server-authoritative geofence validation; never trusts the client's + * insideFence flag. GPS accuracy is credited toward the fence, mirroring + * the client heuristic so both sides agree. + */ +export async function checkGeofence( + cid: string, + latitude: number, + longitude: number, + accuracyMeters: number, +): Promise { + const snapshot = await tenant(cid, "geofences").where("active", "==", true).get(); + if (snapshot.empty) { + return { fencesConfigured: false, insideFence: false, geofenceId: null, distanceMeters: null }; + } + + // Being inside ANY fence is enough. Judging only the nearest centre rejected + // someone standing well inside a large site simply because a small fence + // happened to have its centre closer — overlapping areas are normal when a + // compound and a building inside it are both mapped. + let nearestId: string | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + let insideId: string | null = null; + let insideDistance = Number.POSITIVE_INFINITY; + + for (const doc of snapshot.docs) { + const fence = doc.data() as { latitude: number; longitude: number; radiusMeters: number }; + const distance = haversineMeters(latitude, longitude, fence.latitude, fence.longitude); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestId = doc.id; + } + // GPS accuracy is credited toward the radius, as on the client. + if (distance - accuracyMeters <= fence.radiusMeters && distance < insideDistance) { + insideDistance = distance; + insideId = doc.id; + } + } + + const inside = insideId !== null; + return { + fencesConfigured: true, + insideFence: inside, + // Attribute the punch to the fence it is inside; otherwise report the + // closest one, which is what a manager needs to see to judge the refusal. + geofenceId: inside ? insideId : nearestId, + distanceMeters: Math.round(inside ? insideDistance : nearestDistance), + }; +} diff --git a/backend/functions/src/services/integrity.integration.test.ts b/backend/functions/src/services/integrity.integration.test.ts new file mode 100644 index 0000000..cc5f81d --- /dev/null +++ b/backend/functions/src/services/integrity.integration.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Timestamp } from "firebase-admin/firestore"; +import { db, tenant } from "../lib/firestore"; +import { recomputeAttendanceDay } from "./attendance"; +import { auditAttendanceDays, auditWindow } from "./integrity"; + +/** + * The audit exists because a projection failure was invisible for weeks, so + * the test that matters is: does it actually notice? Each case reproduces the + * production state and asserts the audit reports it. + * + * Skipped unless a Firestore emulator is running (see attendance.integration). + */ + +const KABUL = "Asia/Kabul"; +const DATE = "2026-07-26"; +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); + +let cid = ""; +let seq = 0; + +async function punch(employeeId: string, iso: string, type: "IN" | "OUT"): Promise { + await tenant(cid, "punches") + .doc(`p${String(seq++).padStart(4, "0")}`) + .set({ + companyId: cid, + employeeId, + punchedAt: Timestamp.fromDate(new Date(iso)), + type, + method: "GPS", + latitude: null, + longitude: null, + accuracyMeters: null, + geofenceId: null, + insideFence: false, + kioskId: null, + note: null, + serverValidated: true, + invalidReason: null, + updatedAt: Timestamp.now(), + }); +} + +describe.skipIf(!EMULATOR)("attendance integrity audit", () => { + beforeEach(async () => { + cid = `au_${Date.now()}_${seq}`; + await db.collection("companies").doc(cid).set({ name: "Audit", timezone: KABUL }); + }); + + it("reports a day whose projection was never written", async () => { + // Exactly the production state: punches stored, recompute never ran. + await punch("e1", "2026-07-26T04:00:00Z", "IN"); + await punch("e1", "2026-07-26T12:00:00Z", "OUT"); + + const findings = await auditAttendanceDays(cid, [DATE], KABUL); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + employeeId: "e1", + date: DATE, + problem: "MISSING_DAY", + punchCount: 2, + }); + }); + + it("stays quiet once the day has been computed", async () => { + await punch("e1", "2026-07-26T04:00:00Z", "IN"); + await punch("e1", "2026-07-26T12:00:00Z", "OUT"); + await recomputeAttendanceDay(cid, "e1", DATE, KABUL); + + expect(await auditAttendanceDays(cid, [DATE], KABUL)).toEqual([]); + }); + + it("reports a day left behind by a later punch", async () => { + await punch("e1", "2026-07-26T04:00:00Z", "IN"); + await recomputeAttendanceDay(cid, "e1", DATE, KABUL); + // A punch arriving after the projection was written, whose own recompute + // failed — the day is present but no longer reflects the punch stream. + await new Promise((r) => setTimeout(r, 25)); + await punch("e1", "2026-07-26T12:00:00Z", "OUT"); + + const findings = await auditAttendanceDays(cid, [DATE], KABUL); + + expect(findings).toHaveLength(1); + expect(findings[0].problem).toBe("STALE_DAY"); + }); + + it("reports each affected employee separately", async () => { + await punch("e1", "2026-07-26T04:00:00Z", "IN"); + await punch("e2", "2026-07-26T05:00:00Z", "IN"); + await recomputeAttendanceDay(cid, "e1", DATE, KABUL); // only one repaired + + const findings = await auditAttendanceDays(cid, [DATE], KABUL); + + expect(findings.map((f) => f.employeeId)).toEqual(["e2"]); + }); + + it("says nothing about a day with no punches at all", async () => { + expect(await auditAttendanceDays(cid, [DATE], KABUL)).toEqual([]); + }); +}); + +describe("audit window", () => { + it("covers yesterday and today in the company's zone", () => { + // 00:23 UTC is already the 27th in Kabul, so the window is the 26th–27th + // even though UTC would still call it the 26th. + expect(auditWindow(new Date("2026-07-27T00:23:00Z"), KABUL)).toEqual([ + "2026-07-26", + "2026-07-27", + ]); + }); + + it("includes yesterday so a day still being worked is not flagged", () => { + const [yesterday, today] = auditWindow(new Date("2026-07-27T12:00:00Z"), KABUL); + expect(yesterday).toBe("2026-07-26"); + expect(today).toBe("2026-07-27"); + }); +}); diff --git a/backend/functions/src/services/integrity.ts b/backend/functions/src/services/integrity.ts new file mode 100644 index 0000000..d1447e4 --- /dev/null +++ b/backend/functions/src/services/integrity.ts @@ -0,0 +1,159 @@ +import { Timestamp } from "firebase-admin/firestore"; +import { db, nowTimestamp, tenant } from "../lib/firestore"; +import { localTimeToUtc, type PunchDoc } from "./attendance"; +import { getSettings } from "./settings"; + +/** + * Catches attendance that was recorded but never made it onto the board. + * + * A missing Firestore index made every day projection fail to write for + * weeks. The punches were all there, the error was in the log, and nobody + * looked — the only visible symptom was staff showing as absent. This audits + * the outcome rather than any particular cause: if an employee has punches + * for a day, that day must exist and must be at least as new as its punches. + */ + +export type IntegrityProblem = "MISSING_DAY" | "STALE_DAY"; + +export interface IntegrityFinding { + companyId: string; + employeeId: string; + date: string; + problem: IntegrityProblem; + punchCount: number; +} + +/** Punches for one company-local day, grouped by employee. */ +async function punchesByEmployee( + cid: string, + date: string, + timezone: string, +): Promise> { + const start = localTimeToUtc(date, "00:00", timezone); + const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); + + const snap = await tenant(cid, "punches") + .where("punchedAt", ">=", Timestamp.fromDate(start)) + .where("punchedAt", "<", Timestamp.fromDate(end)) + .get(); + + const byEmployee = new Map(); + for (const doc of snap.docs) { + const punch = doc.data() as PunchDoc; + // Refused punches are not expected to produce worked time, but they still + // require a day document — that is where their reason is surfaced. + const list = byEmployee.get(punch.employeeId) ?? []; + list.push(punch); + byEmployee.set(punch.employeeId, list); + } + return byEmployee; +} + +/** Every employee/day in [dates] whose projection is missing or out of date. */ +export async function auditAttendanceDays( + cid: string, + dates: string[], + timezone: string, +): Promise { + const findings: IntegrityFinding[] = []; + + for (const date of dates) { + const byEmployee = await punchesByEmployee(cid, date, timezone); + if (byEmployee.size === 0) { + continue; + } + + const refs = [...byEmployee.keys()].map((employeeId) => + tenant(cid, "attendanceDays").doc(`${employeeId}_${date}`), + ); + const daySnaps = await db.getAll(...refs); + + [...byEmployee.entries()].forEach(([employeeId, punches], index) => { + const snap = daySnaps[index]; + if (!snap.exists) { + findings.push({ + companyId: cid, + employeeId, + date, + problem: "MISSING_DAY", + punchCount: punches.length, + }); + return; + } + // A day computed before its newest punch has not seen that punch, which + // is what a silently failed recompute looks like after the fact. + const computedAt = (snap.data() as { computedAt?: Timestamp }).computedAt; + const newestPunch = punches.reduce( + (max, p) => (p.updatedAt.toMillis() > max ? p.updatedAt.toMillis() : max), + 0, + ); + if (!computedAt || computedAt.toMillis() < newestPunch) { + findings.push({ + companyId: cid, + employeeId, + date, + problem: "STALE_DAY", + punchCount: punches.length, + }); + } + }); + } + + return findings; +} + +/** The company-local dates the daily audit covers: today and yesterday. */ +export function auditWindow(now: Date, timezone: string): string[] { + const fmt = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + // Yesterday is included because a day is still accumulating punches until + // its own midnight, so "today" alone would flag work in progress. + return [fmt.format(new Date(now.getTime() - 24 * 60 * 60 * 1000)), fmt.format(now)]; +} + +export interface AuditReport { + checkedAt: Timestamp; + companiesChecked: number; + findings: IntegrityFinding[]; +} + +/** + * Audits every company and records the outcome. + * + * Findings are logged at error level with a stable marker so a log-based + * alert can be attached to them, and stored so the result is visible without + * reading logs at all. + */ +export async function runAttendanceAudit(now: Date = new Date()): Promise { + const companies = await db.collection("companies").select().get(); + const findings: IntegrityFinding[] = []; + + for (const company of companies.docs) { + const timezone = (await getSettings(company.id)).profile.timezone; + findings.push(...(await auditAttendanceDays(company.id, auditWindow(now, timezone), timezone))); + } + + const report: AuditReport = { + checkedAt: nowTimestamp(), + companiesChecked: companies.size, + findings, + }; + + if (findings.length > 0) { + // ATTENDANCE_INTEGRITY is the string to alert on. + console.error("ATTENDANCE_INTEGRITY", { + findingCount: findings.length, + companiesChecked: companies.size, + sample: findings.slice(0, 20), + }); + } else { + console.log("ATTENDANCE_INTEGRITY ok", { companiesChecked: companies.size }); + } + + await db.collection("integrityReports").add(report); + return report; +} diff --git a/backend/functions/src/services/invite.ts b/backend/functions/src/services/invite.ts new file mode 100644 index 0000000..b33fba0 --- /dev/null +++ b/backend/functions/src/services/invite.ts @@ -0,0 +1,85 @@ +import { getAuth } from "firebase-admin/auth"; +import { randomBytes } from "crypto"; +import { ApiError, ErrorCodes } from "../lib/errors"; + +/** Roles a manager can assign when onboarding an employee (not COMPANY_ADMIN). */ +export const ASSIGNABLE_ROLES = [ + "EMPLOYEE", + "TEAM_LEAD", + "BRANCH_MANAGER", + "HR_ADMIN", + "PAYROLL_ADMIN", + "AUDITOR", +] as const; + +export type AssignableRole = (typeof ASSIGNABLE_ROLES)[number]; + +/** + * Human-shareable temporary password. Email-based invites are a P-next + * enhancement (see docs/12); until then the manager shares this with the new + * employee, who signs into the mobile app with it. + */ +export function generateTempPassword(): string { + // e.g. "Wt-7Kd9Qp2r" — avoids ambiguous chars, always meets the 8-char rule. + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"; + const bytes = randomBytes(9); + let out = ""; + for (const b of bytes) out += alphabet[b % alphabet.length]; + return `Wt-${out}`; +} + +/** + * Creates the Firebase Auth login for an employee and stamps their tenant/RBAC + * claims. uid == employeeId so GET /me resolves the same document. Returns the + * password that was set (caller shares it). + */ +export async function createEmployeeLogin(params: { + companyId: string; + employeeId: string; + email: string; + displayName: string; + role: AssignableRole; + branchIds: string[]; + password?: string; +}): Promise { + const auth = getAuth(); + + const existing = await auth.getUserByEmail(params.email).catch(() => null); + if (existing) { + throw new ApiError(409, ErrorCodes.CONFLICT, "An account with this email already exists"); + } + + const password = params.password ?? generateTempPassword(); + await auth.createUser({ + uid: params.employeeId, + email: params.email, + password, + displayName: params.displayName, + }); + await auth.setCustomUserClaims(params.employeeId, { + cid: params.companyId, + eid: params.employeeId, + r: [params.role], + b: params.branchIds, + }); + return password; +} + +/** + * Sets an employee's login password. When `password` is given, that exact + * (permanent) password is used; otherwise a fresh random one is generated. + * Returns the password that was set so the manager can share it. + */ +export async function resetEmployeePassword( + employeeId: string, + password?: string, +): Promise { + const auth = getAuth(); + const user = await auth.getUser(employeeId).catch(() => null); + if (!user) { + throw ApiError.notFound("This employee has no login account"); + } + const newPassword = password ?? generateTempPassword(); + await auth.updateUser(employeeId, { password: newPassword }); + return newPassword; +} diff --git a/backend/functions/src/services/kiosk-account.ts b/backend/functions/src/services/kiosk-account.ts new file mode 100644 index 0000000..e64168a --- /dev/null +++ b/backend/functions/src/services/kiosk-account.ts @@ -0,0 +1,104 @@ +import { getAuth } from "firebase-admin/auth"; +import { z } from "zod"; +import { ApiError } from "../lib/errors"; +import { audit, nowTimestamp, tenant, toIso } from "../lib/firestore"; +import { ulid } from "../lib/ids"; +import { generateTempPassword } from "./invite"; + +export const kioskAccountCreateSchema = z.object({ + label: z.string().min(1).max(80), + branchId: z.string().nullish(), +}); + +export type KioskAccountCreate = z.infer; + +/** Synthetic, unique login for an unattended kiosk device. */ +function kioskEmail(kioskId: string): string { + return `${kioskId}@kiosk.worktrack.app`; +} + +/** + * Provisions a dedicated KIOSK-role login so a wall-mounted tablet can stay on + * the check-in screen without a manager's personal account. The account has no + * employee record — its only power is kiosk:issue (minting rotating QR tokens). + * Returns the credentials once; the admin types them into the device. + */ +export async function createKioskAccount( + cid: string, + payload: KioskAccountCreate, + actorId: string, + roles: string[], +): Promise> { + const auth = getAuth(); + const kioskId = `kiosk-${ulid().slice(0, 16).toLowerCase()}`; + const email = kioskEmail(kioskId); + const password = generateTempPassword(); + + await auth.createUser({ uid: kioskId, email, password, displayName: payload.label }); + await auth.setCustomUserClaims(kioskId, { + cid, + eid: kioskId, + r: ["KIOSK"], + b: payload.branchId ? [payload.branchId] : [], + }); + + const now = nowTimestamp(); + await tenant(cid, "devices").doc(kioskId).set({ + companyId: cid, + kioskId, + type: "KIOSK", + label: payload.label, + email, + branchId: payload.branchId ?? null, + active: true, + createdBy: actorId, + createdAt: now, + updatedAt: now, + }); + + await audit(cid, { + actorId, + actorRole: roles.join(","), + action: "kiosk.account.create", + resourceType: "devices", + resourceId: kioskId, + after: { label: payload.label, email }, + }); + + return { kioskId, label: payload.label, email, password, branchId: payload.branchId ?? null }; +} + +export async function listKioskAccounts(cid: string): Promise[]> { + const snap = await tenant(cid, "devices").where("type", "==", "KIOSK").limit(100).get(); + return snap.docs.map((doc) => { + const d = doc.data() as { + label?: string; + email?: string; + branchId?: string | null; + active?: boolean; + createdAt?: FirebaseFirestore.Timestamp; + }; + return { + kioskId: doc.id, + label: d.label ?? doc.id, + email: d.email ?? null, + branchId: d.branchId ?? null, + active: d.active ?? true, + createdAt: toIso(d.createdAt ?? null), + }; + }); +} + +/** Resets a kiosk device's password (e.g. re-provisioning a lost tablet). */ +export async function resetKioskPassword( + cid: string, + kioskId: string, +): Promise> { + const doc = await tenant(cid, "devices").doc(kioskId).get(); + if (!doc.exists) { + throw ApiError.notFound("Kiosk account not found"); + } + const password = generateTempPassword(); + await getAuth().updateUser(kioskId, { password }); + return { kioskId, password }; +} diff --git a/backend/functions/src/services/kiosk.ts b/backend/functions/src/services/kiosk.ts new file mode 100644 index 0000000..242988a --- /dev/null +++ b/backend/functions/src/services/kiosk.ts @@ -0,0 +1,58 @@ +import { createHmac, timingSafeEqual } from "crypto"; + +/** + * Kiosk QR tokens: `kioskId.slot.signature` where slot = floor(unixSeconds/30) + * and signature = HMAC-SHA256(secret, `${kioskId}.${slot}`) hex. The kiosk app + * regenerates the QR every 30 seconds; scanning a stale or forged code fails. + */ +export interface KioskToken { + kioskId: string; + slot: number; +} + +export function signKioskToken( + secret: string, + kioskId: string, + slot: number = currentSlot(), +): string { + return `${kioskId}.${slot}.${signature(secret, kioskId, slot)}`; +} + +/** + * Verifies the token against the current slot ± 1 (90-second acceptance + * window covers clock skew and scan latency). Returns null when invalid. + */ +export function verifyKioskToken(secret: string, token: string): KioskToken | null { + const parts = token.split("."); + if (parts.length !== 3) { + return null; + } + const [kioskId, slotRaw, provided] = parts; + const slot = Number.parseInt(slotRaw, 10); + if (!kioskId || Number.isNaN(slot)) { + return null; + } + + const now = currentSlot(); + for (const candidate of [now, now - 1, now + 1]) { + if (candidate !== slot) { + continue; + } + const expected = signature(secret, kioskId, candidate); + if ( + provided.length === expected.length && + timingSafeEqual(Buffer.from(provided, "utf8"), Buffer.from(expected, "utf8")) + ) { + return { kioskId, slot: candidate }; + } + } + return null; +} + +function currentSlot(): number { + return Math.floor(Date.now() / 1000 / 30); +} + +function signature(secret: string, kioskId: string, slot: number): string { + return createHmac("sha256", secret).update(`${kioskId}.${slot}`).digest("hex"); +} diff --git a/backend/functions/src/services/leave.ts b/backend/functions/src/services/leave.ts new file mode 100644 index 0000000..57a2fc6 --- /dev/null +++ b/backend/functions/src/services/leave.ts @@ -0,0 +1,400 @@ +import { Timestamp } from "firebase-admin/firestore"; +import { z } from "zod"; +import { ApiError, ErrorCodes } from "../lib/errors"; +import { canDecideAnyRequest } from "../middleware/rbac"; +import { isValidUlid } from "../lib/ids"; +import { audit, db, nowTimestamp, tenant, toIso } from "../lib/firestore"; +import { notify } from "./notifications"; + +export const leaveCreateSchema = z.object({ + id: z.string().length(26), + leaveTypeId: z.string().min(1), + startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + startHalfDay: z.boolean().optional().default(false), + endHalfDay: z.boolean().optional().default(false), + reason: z.string().min(1).max(1000), +}); + +export type LeaveCreate = z.infer; + +export const leaveDecisionSchema = z.object({ + decision: z.enum(["APPROVE", "REJECT"]), + note: z.string().max(1000).nullish(), +}); + +interface LeaveRequestDoc { + companyId: string; + employeeId: string; + employeeName: string | null; + leaveTypeId: string; + startDate: string; + endDate: string; + startHalfDay: boolean; + endHalfDay: boolean; + days: number; + reason: string; + status: "PENDING" | "APPROVED" | "REJECTED" | "CANCELLED"; + currentApproverId: string | null; + decidedAt: Timestamp | null; + decidedBy: string | null; + decisionNote: string | null; + createdAt: Timestamp; + updatedAt: Timestamp; +} + +export function leaveRequestToDto(id: string, doc: LeaveRequestDoc): Record { + return { + id, + companyId: doc.companyId, + employeeId: doc.employeeId, + employeeName: doc.employeeName, + leaveTypeId: doc.leaveTypeId, + startDate: doc.startDate, + endDate: doc.endDate, + startHalfDay: doc.startHalfDay, + endHalfDay: doc.endHalfDay, + days: doc.days, + reason: doc.reason, + status: doc.status, + currentApproverId: doc.currentApproverId, + decidedAt: toIso(doc.decidedAt), + decisionNote: doc.decisionNote, + createdAt: toIso(doc.createdAt), + updatedAt: toIso(doc.updatedAt), + }; +} + +/** Calendar-day count with half-day trims; must match the client's estimate. */ +export function calculateDays(payload: LeaveCreate): number { + const start = new Date(`${payload.startDate}T00:00:00Z`); + const end = new Date(`${payload.endDate}T00:00:00Z`); + const span = Math.floor((end.getTime() - start.getTime()) / 86_400_000) + 1; + if (span <= 0) { + return 0; + } + if (span === 1) { + return payload.startHalfDay || payload.endHalfDay ? 0.5 : 1; + } + let days = span; + if (payload.startHalfDay) days -= 0.5; + if (payload.endHalfDay) days -= 0.5; + return days; +} + +/** + * Creates a leave request transactionally: validates the authoritative balance, + * reserves pendingDays, and routes to the employee's manager for approval. + * Idempotent on the client-generated ULID. + */ +/** + * The requests a person may act on. + * + * "mine" is the caller's own history. "approvals" is the queue, and it must + * agree with decideLeaveRequest about who may act: an administrator may decide + * any pending request, so an administrator sees every pending request. Anyone + * else sees only what was routed to them, which happens only when the employee + * has a manager — a field the portal has no input for, so in most companies + * nothing is routed anywhere and only the administrator view is populated. + */ +export async function listLeaveRequests( + cid: string, + employeeId: string, + roles: string[], + scope: string, +): Promise>> { + const col = tenant(cid, "leaveRequests"); + const query = + scope !== "approvals" + ? col.where("employeeId", "==", employeeId).limit(200) + : canDecideAnyRequest(roles) + ? col.where("status", "==", "PENDING").limit(200) + : col.where("currentApproverId", "==", employeeId).limit(200); + + const snapshot = await query.get(); + return snapshot.docs.map((doc) => leaveRequestToDto(doc.id, doc.data() as LeaveRequestDoc)); +} + +export async function createLeaveRequest( + cid: string, + employeeId: string, + payload: LeaveCreate, +): Promise> { + if (!isValidUlid(payload.id)) { + throw ApiError.validation("Request id must be a ULID", { id: "Invalid ULID" }); + } + const days = calculateDays(payload); + if (days <= 0) { + throw ApiError.validation("End date must be on or after start date", { + endDate: "Invalid range", + }); + } + + const requestRef = tenant(cid, "leaveRequests").doc(payload.id); + const periodYear = Number(payload.startDate.slice(0, 4)); + + return db.runTransaction(async (tx) => { + const existing = await tx.get(requestRef); + if (existing.exists) { + return leaveRequestToDto(payload.id, existing.data() as LeaveRequestDoc); + } + + const employeeSnap = await tx.get(tenant(cid, "employees").doc(employeeId)); + if (!employeeSnap.exists) { + throw ApiError.notFound("Employee record not found"); + } + const employee = employeeSnap.data() as { + firstName?: string; + lastName?: string; + managerId?: string | null; + }; + + const balanceQuery = tenant(cid, "leaveBalances") + .where("employeeId", "==", employeeId) + .where("leaveTypeId", "==", payload.leaveTypeId) + .where("periodYear", "==", periodYear) + .limit(1); + const balanceSnap = await tx.get(balanceQuery); + + // No balance row used to mean no limit, and POST /employees never created + // one — so every employee added through the portal had unlimited leave. + // Absence of an entitlement is now a refusal, not a free pass. + if (balanceSnap.empty) { + throw ApiError.business( + ErrorCodes.INSUFFICIENT_LEAVE_BALANCE, + "No leave entitlement is configured for this employee and leave type", + ); + } + { + const balance = balanceSnap.docs[0].data() as { + entitledDays: number; + accruedDays: number; + usedDays: number; + carriedOverDays: number; + pendingDays: number; + }; + const available = + balance.entitledDays + + balance.accruedDays + + balance.carriedOverDays - + balance.usedDays - + balance.pendingDays; + if (days > available) { + throw ApiError.business( + ErrorCodes.INSUFFICIENT_LEAVE_BALANCE, + `Requested ${days} days but only ${available.toFixed(1)} available`, + ); + } + tx.update(balanceSnap.docs[0].ref, { + pendingDays: balance.pendingDays + days, + updatedAt: nowTimestamp(), + }); + } + + const now = nowTimestamp(); + const doc: LeaveRequestDoc = { + companyId: cid, + employeeId, + employeeName: + [employee.firstName, employee.lastName].filter(Boolean).join(" ") || null, + leaveTypeId: payload.leaveTypeId, + startDate: payload.startDate, + endDate: payload.endDate, + startHalfDay: payload.startHalfDay, + endHalfDay: payload.endHalfDay, + days, + reason: payload.reason, + status: "PENDING", + currentApproverId: employee.managerId ?? null, + decidedAt: null, + decidedBy: null, + decisionNote: null, + createdAt: now, + updatedAt: now, + }; + tx.create(requestRef, doc); + return leaveRequestToDto(payload.id, doc); + }); +} + +/** Approves or rejects a PENDING request; moves the pendingDays reservation. */ +export async function decideLeaveRequest( + cid: string, + requestId: string, + decidedBy: string, + deciderRoles: string[], + decision: "APPROVE" | "REJECT", + note: string | null, +): Promise> { + const requestRef = tenant(cid, "leaveRequests").doc(requestId); + + const dto = await db.runTransaction(async (tx) => { + const snap = await tx.get(requestRef); + if (!snap.exists) { + throw ApiError.notFound("Leave request not found"); + } + const request = snap.data() as LeaveRequestDoc; + if (request.status !== "PENDING") { + throw ApiError.business(ErrorCodes.INVALID_STATE, `Request is already ${request.status}`); + } + + const isAssignedApprover = request.currentApproverId === decidedBy; + const isAdmin = deciderRoles.includes("HR_ADMIN") || deciderRoles.includes("COMPANY_ADMIN"); + if (!isAssignedApprover && !isAdmin) { + throw ApiError.permissionDenied("You are not the approver for this request"); + } + if (request.employeeId === decidedBy) { + throw ApiError.permissionDenied("You cannot decide your own leave request"); + } + + const periodYear = Number(request.startDate.slice(0, 4)); + const balanceQuery = tenant(cid, "leaveBalances") + .where("employeeId", "==", request.employeeId) + .where("leaveTypeId", "==", request.leaveTypeId) + .where("periodYear", "==", periodYear) + .limit(1); + const balanceSnap = await tx.get(balanceQuery); + + if (!balanceSnap.empty) { + const balance = balanceSnap.docs[0].data() as { pendingDays: number; usedDays: number }; + const releasedPending = Math.max(0, balance.pendingDays - request.days); + tx.update(balanceSnap.docs[0].ref, { + pendingDays: releasedPending, + usedDays: decision === "APPROVE" ? balance.usedDays + request.days : balance.usedDays, + updatedAt: nowTimestamp(), + }); + } + + const updated: Partial = { + status: decision === "APPROVE" ? "APPROVED" : "REJECTED", + decidedAt: nowTimestamp(), + decidedBy, + decisionNote: note, + currentApproverId: null, + updatedAt: nowTimestamp(), + }; + tx.update(requestRef, updated); + return leaveRequestToDto(requestId, { ...request, ...updated } as LeaveRequestDoc); + }); + + // Approving leave used to touch nothing but the request itself, so the person + // on approved leave still showed as ABSENT on the attendance board, the + // dashboard's on-leave tile stayed at zero, and payroll's paidLeaveDays was + // always zero. Mark the days so all three read the truth. + if (decision === "APPROVE") { + await markLeaveDays(cid, dto); + } + + // Told, at last. Before this the employee found out by opening the app and + // looking, which mostly meant finding out by asking somebody in person. + await notify(cid, { + employeeId: (dto.employeeId as string) ?? "", + kind: "LEAVE_DECIDED", + title: decision === "APPROVE" ? "رخصتی شما تأیید شد" : "رخصتی شما رد شد", + body: + decision === "APPROVE" + ? `از ${String(dto.startDate)} تا ${String(dto.endDate)}` + : note?.trim() + ? note + : `از ${String(dto.startDate)} تا ${String(dto.endDate)}`, + link: "/leave", + }); + + await audit(cid, { + actorId: decidedBy, + actorRole: deciderRoles.join(","), + action: `leave.${decision.toLowerCase()}`, + resourceType: "leaveRequests", + resourceId: requestId, + after: { decision, note }, + }); + return dto; +} + +/** Owner-initiated cancellation of a PENDING request; releases the reservation. */ +export async function cancelLeaveRequest( + cid: string, + requestId: string, + employeeId: string, +): Promise> { + const requestRef = tenant(cid, "leaveRequests").doc(requestId); + + return db.runTransaction(async (tx) => { + const snap = await tx.get(requestRef); + if (!snap.exists) { + throw ApiError.notFound("Leave request not found"); + } + const request = snap.data() as LeaveRequestDoc; + if (request.employeeId !== employeeId) { + throw ApiError.permissionDenied("Only the requester can cancel"); + } + if (request.status !== "PENDING") { + throw ApiError.business(ErrorCodes.INVALID_STATE, `Request is already ${request.status}`); + } + + const periodYear = Number(request.startDate.slice(0, 4)); + const balanceSnap = await tx.get( + tenant(cid, "leaveBalances") + .where("employeeId", "==", request.employeeId) + .where("leaveTypeId", "==", request.leaveTypeId) + .where("periodYear", "==", periodYear) + .limit(1), + ); + if (!balanceSnap.empty) { + const balance = balanceSnap.docs[0].data() as { pendingDays: number }; + tx.update(balanceSnap.docs[0].ref, { + pendingDays: Math.max(0, balance.pendingDays - request.days), + updatedAt: nowTimestamp(), + }); + } + + const updated: Partial = { + status: "CANCELLED", + currentApproverId: null, + updatedAt: nowTimestamp(), + }; + tx.update(requestRef, updated); + return leaveRequestToDto(requestId, { ...request, ...updated } as LeaveRequestDoc); + }); +} + +/** + * Stamps LEAVE onto each attendance day the approved request covers. + * + * Merged, not replaced: a day may already carry punches (someone who worked a + * half day before going home) and that evidence is not the approval's to erase. + */ +async function markLeaveDays(cid: string, dto: Record): Promise { + const employeeId = String(dto.employeeId ?? ""); + const startDate = String(dto.startDate ?? ""); + const endDate = String(dto.endDate ?? ""); + if (!employeeId || !/^\d{4}-\d{2}-\d{2}$/.test(startDate) || !/^\d{4}-\d{2}-\d{2}$/.test(endDate)) { + return; + } + + const now = nowTimestamp(); + const batch = db.batch(); + let written = 0; + for ( + let d = new Date(`${startDate}T00:00:00Z`); + d <= new Date(`${endDate}T00:00:00Z`) && written < 120; // a sane upper bound + d = new Date(d.getTime() + 24 * 60 * 60 * 1000) + ) { + const dateIso = d.toISOString().slice(0, 10); + batch.set( + tenant(cid, "attendanceDays").doc(`${employeeId}_${dateIso}`), + { + employeeId, + date: dateIso, + status: "LEAVE", + leaveRequestId: dto.id ?? null, + updatedAt: now, + }, + { merge: true }, + ); + written++; + } + if (written > 0) { + await batch.commit(); + } +} diff --git a/backend/functions/src/services/license.integration.test.ts b/backend/functions/src/services/license.integration.test.ts new file mode 100644 index 0000000..d28ef3e --- /dev/null +++ b/backend/functions/src/services/license.integration.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { db, tenant } from "../lib/firestore"; +import { + activateDevice, + getLicense, + listDevices, + setDeviceStatus, + setLicense, + DEFAULT_LICENSE, +} from "./license"; + +/** + * Per-device licensing. The seat count and the write that depends on it happen + * in one transaction: counting first and writing after would let two tablets + * set up side by side both read "one seat left" and both take it. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); +const TODAY = "2026-08-24"; +let cid = ""; +let seq = 0; + +function device(id: string) { + return { deviceId: id, platform: "ANDROID" as const, model: "Pixel 10", appVersion: "1.0.0" }; +} + +async function license(over: Record = {}) { + await setLicense(cid, { + plan: "STANDARD", + deviceLimit: 2, + status: "ACTIVE", + expiresAt: null, + enforceDevices: true, + ...over, + } as Parameters[1]); +} + +describe.skipIf(!EMULATOR)("device licensing", () => { + beforeEach(async () => { + seq += 1; + cid = `lic_${Date.now()}_${seq}`; + await db.collection("companies").doc(cid).set({ + name: "Licensed Co", + settings: { profile: { currency: "AFN", timezone: "Asia/Kabul" } }, + }); + }); + + it("falls back to a default licence when none is on file", async () => { + expect(await getLicense(cid)).toEqual(DEFAULT_LICENSE); + }); + + it("does not enforce devices until a company opts in", async () => { + // Shipping this must not lock out the app builds already in the field. + expect(DEFAULT_LICENSE.enforceDevices).toBe(false); + }); + + it("takes a seat when a new device activates", async () => { + await license(); + + const result = await activateDevice(cid, "e1", device("device-aaa1"), TODAY); + + expect(result.seatTaken).toBe(true); + expect(result.devicesInUse).toBe(1); + expect(result.device.status).toBe("ACTIVE"); + }); + + it("does not take a second seat when the same device comes back", async () => { + await license(); + await activateDevice(cid, "e1", device("device-aaa1"), TODAY); + + const again = await activateDevice(cid, "e1", device("device-aaa1"), TODAY); + + expect(again.seatTaken).toBe(false); + expect(again.devicesInUse).toBe(1); + }); + + it("refuses a device once every seat is taken", async () => { + await license(); // two seats + await activateDevice(cid, "e1", device("device-aaa1"), TODAY); + await activateDevice(cid, "e2", device("device-bbb2"), TODAY); + + await expect( + activateDevice(cid, "e3", device("device-ccc3"), TODAY), + ).rejects.toMatchObject({ status: 403, code: "LICENSE_LIMIT_REACHED" }); + }); + + it("never oversubscribes when devices activate simultaneously", async () => { + await license({ deviceLimit: 3 }); + + const results = await Promise.allSettled( + Array.from({ length: 8 }, (_, i) => + activateDevice(cid, `e${i}`, device(`device-race-${i}`), TODAY), + ), + ); + + expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(3); + const active = (await listDevices(cid)).filter((d) => d.status === "ACTIVE"); + expect(active).toHaveLength(3); + }, 30_000); + + it("frees the seat when a device is revoked", async () => { + await license({ deviceLimit: 1 }); + await activateDevice(cid, "e1", device("device-aaa1"), TODAY); + await expect( + activateDevice(cid, "e2", device("device-bbb2"), TODAY), + ).rejects.toMatchObject({ code: "LICENSE_LIMIT_REACHED" }); + + await setDeviceStatus(cid, "device-aaa1", "REVOKED"); + + const replacement = await activateDevice(cid, "e2", device("device-bbb2"), TODAY); + expect(replacement.seatTaken).toBe(true); + }); + + it("refuses a revoked device rather than silently re-admitting it", async () => { + await license(); + await activateDevice(cid, "e1", device("device-aaa1"), TODAY); + await setDeviceStatus(cid, "device-aaa1", "REVOKED"); + + await expect( + activateDevice(cid, "e1", device("device-aaa1"), TODAY), + ).rejects.toMatchObject({ status: 403, code: "DEVICE_REVOKED" }); + }); + + it("counts a kiosk against the licence", async () => { + await license({ deviceLimit: 1 }); + // Kiosk records predate this feature and carry `active`, not `status`. + await tenant(cid, "devices").doc("kiosk-1").set({ + companyId: cid, + type: "KIOSK", + label: "Gate tablet", + active: true, + }); + + await expect( + activateDevice(cid, "e1", device("device-aaa1"), TODAY), + ).rejects.toMatchObject({ code: "LICENSE_LIMIT_REACHED" }); + }); + + it("does not let a deactivated kiosk keep holding a seat", async () => { + await license({ deviceLimit: 1 }); + await tenant(cid, "devices").doc("kiosk-1").set({ + companyId: cid, + type: "KIOSK", + active: false, + }); + + const result = await activateDevice(cid, "e1", device("device-aaa1"), TODAY); + expect(result.seatTaken).toBe(true); + }); + + it("refuses activation on a suspended licence", async () => { + await license({ status: "SUSPENDED" }); + + await expect( + activateDevice(cid, "e1", device("device-aaa1"), TODAY), + ).rejects.toMatchObject({ status: 403, code: "LICENSE_INACTIVE" }); + }); + + it("refuses activation once the licence has expired", async () => { + await license({ expiresAt: "2026-08-23" }); + + await expect( + activateDevice(cid, "e1", device("device-aaa1"), TODAY), + ).rejects.toMatchObject({ code: "LICENSE_INACTIVE" }); + }); + + it("still works on the last day of the licence", async () => { + await license({ expiresAt: TODAY }); + + const result = await activateDevice(cid, "e1", device("device-aaa1"), TODAY); + expect(result.seatTaken).toBe(true); + }); +}); diff --git a/backend/functions/src/services/license.ts b/backend/functions/src/services/license.ts new file mode 100644 index 0000000..9a0d404 --- /dev/null +++ b/backend/functions/src/services/license.ts @@ -0,0 +1,291 @@ +import { Timestamp } from "firebase-admin/firestore"; +import { z } from "zod"; +import { ApiError, ErrorCodes } from "../lib/errors"; +import { db, nowTimestamp, tenant, toIso } from "../lib/firestore"; + +/** + * Per-device licensing. + * + * A company's licence grants a number of device seats. Every phone running the + * employee app and every kiosk tablet occupies one seat, identified by a stable + * id the client generates once and keeps. Activating a device that is already + * registered refreshes it and costs nothing; activating a new one takes a seat, + * and is refused when none are left. + * + * Seat accounting runs in a transaction. Counting seats and then writing the + * device as two steps would let two tablets set up side by side both see "one + * seat left" and both take it — the same class of bug the idempotency and + * expense paths had. + */ + +/** Seats granted when a company has no licence on file. */ +export const DEFAULT_LICENSE: License = { + plan: "FREE", + deviceLimit: 5, + status: "ACTIVE", + expiresAt: null, + // Off until the vendor issues a licence that turns it on. A company with no + // licence on file is a trial or a pre-sale tenant, and gets a working product + // with a generous seat count rather than a locked one. + enforceDevices: false, +}; + +export type LicensePlan = "FREE" | "STANDARD" | "ENTERPRISE"; +export type LicenseStatus = "ACTIVE" | "SUSPENDED" | "EXPIRED"; + +export interface License { + plan: LicensePlan; + deviceLimit: number; + status: LicenseStatus; + /** YYYY-MM-DD, or null for a perpetual licence. */ + expiresAt: string | null; + enforceDevices: boolean; +} + +export const licenseWriteSchema = z.object({ + plan: z.enum(["FREE", "STANDARD", "ENTERPRISE"]), + deviceLimit: z.number().int().min(1).max(100_000), + status: z.enum(["ACTIVE", "SUSPENDED", "EXPIRED"]), + expiresAt: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD") + .nullish(), + enforceDevices: z.boolean(), +}); + +export const deviceActivateSchema = z.object({ + deviceId: z.string().min(8).max(128).regex(/^[A-Za-z0-9_-]+$/, "Use A–Z, 0–9, _ and -"), + platform: z.enum(["ANDROID", "IOS", "WEB"]), + model: z.string().max(80).nullish(), + appVersion: z.string().max(40).nullish(), +}); + +export type DeviceActivation = z.infer; + +export interface DeviceDto { + deviceId: string; + type: string; + label: string | null; + platform: string | null; + model: string | null; + appVersion: string | null; + employeeId: string | null; + branchId: string | null; + status: "ACTIVE" | "REVOKED"; + activatedAt: string | null; + lastSeenAt: string | null; +} + +export interface DeviceDoc { + type?: string; + label?: string | null; + platform?: string | null; + model?: string | null; + appVersion?: string | null; + employeeId?: string | null; + branchId?: string | null; + status?: "ACTIVE" | "REVOKED"; + /** Kiosk records predate `status` and carry this instead. */ + active?: boolean; + activatedAt?: Timestamp | null; + lastSeenAt?: Timestamp | null; +} + +/** + * A device counts against the licence unless it has been revoked. Kiosk records + * were written before `status` existed and carry `active` instead, so both are + * honoured — a kiosk someone deactivated must not keep holding a seat. + */ +export function isDeviceActive(doc: DeviceDoc): boolean { + if (doc.status === "REVOKED") return false; + if (doc.active === false) return false; + return true; +} + +function toDeviceDto(deviceId: string, doc: DeviceDoc): DeviceDto { + return { + deviceId, + type: doc.type ?? "MOBILE", + label: doc.label ?? null, + platform: doc.platform ?? null, + model: doc.model ?? null, + appVersion: doc.appVersion ?? null, + employeeId: doc.employeeId ?? null, + branchId: doc.branchId ?? null, + status: isDeviceActive(doc) ? "ACTIVE" : "REVOKED", + activatedAt: toIso(doc.activatedAt ?? null), + lastSeenAt: toIso(doc.lastSeenAt ?? null), + }; +} + +export async function getLicense(cid: string): Promise { + const snap = await db.collection("companies").doc(cid).get(); + const stored = snap.data()?.license as Partial | undefined; + return { + plan: stored?.plan ?? DEFAULT_LICENSE.plan, + deviceLimit: stored?.deviceLimit ?? DEFAULT_LICENSE.deviceLimit, + status: stored?.status ?? DEFAULT_LICENSE.status, + expiresAt: stored?.expiresAt ?? DEFAULT_LICENSE.expiresAt, + enforceDevices: stored?.enforceDevices ?? DEFAULT_LICENSE.enforceDevices, + }; +} + +export async function setLicense(cid: string, input: z.infer): Promise { + const license: License = { + plan: input.plan, + deviceLimit: input.deviceLimit, + status: input.status, + expiresAt: input.expiresAt ?? null, + enforceDevices: input.enforceDevices, + }; + await db.collection("companies").doc(cid).set( + { license, updatedAt: nowTimestamp() }, + { merge: true }, + ); + return license; +} + +/** A licence is usable when it is ACTIVE and has not run out. */ +export function licenseUsable(license: License, today: string): boolean { + if (license.status !== "ACTIVE") return false; + if (license.expiresAt !== null && license.expiresAt < today) return false; + return true; +} + +export interface ActivationResult { + device: DeviceDto; + deviceLimit: number; + devicesInUse: number; + /** True when this call took a new seat rather than refreshing one. */ + seatTaken: boolean; +} + +/** + * Registers a device against the company's licence, or refreshes it if it is + * already registered. Refused when the licence is not usable, when the device + * was revoked, or when every seat is taken. + */ +export async function activateDevice( + cid: string, + employeeId: string, + input: DeviceActivation, + today: string, +): Promise { + const license = await getLicense(cid); + if (!licenseUsable(license, today)) { + throw new ApiError( + 403, + ErrorCodes.LICENSE_INACTIVE, + license.status === "ACTIVE" + ? "This company's licence has expired" + : `This company's licence is ${license.status.toLowerCase()}`, + ); + } + + const devices = tenant(cid, "devices"); + const ref = devices.doc(input.deviceId); + const now = nowTimestamp(); + + return db.runTransaction(async (tx) => { + // Every read happens before any write: Firestore rejects a transaction that + // reads after writing, and the refresh path below used to do exactly that. + const existing = await tx.get(ref); + const inUse = await countActive(tx, devices); + + if (existing.exists) { + const doc = existing.data() as DeviceDoc; + if (!isDeviceActive(doc)) { + throw new ApiError( + 403, + ErrorCodes.DEVICE_REVOKED, + "This device has been revoked. Ask an administrator to re-enable it.", + ); + } + // Already holds a seat — refresh it without touching the count. + const updated: DeviceDoc = { + ...doc, + platform: input.platform, + model: input.model ?? doc.model ?? null, + appVersion: input.appVersion ?? null, + employeeId, + lastSeenAt: now, + }; + tx.set(ref, { ...updated, updatedAt: now }, { merge: true }); + + // This device already holds one of the counted seats. + return { + device: toDeviceDto(input.deviceId, updated), + deviceLimit: license.deviceLimit, + devicesInUse: inUse, + seatTaken: false, + }; + } + + if (inUse >= license.deviceLimit) { + throw new ApiError( + 403, + ErrorCodes.LICENSE_LIMIT_REACHED, + `All ${license.deviceLimit} device seats on this licence are in use. Revoke a device or upgrade the licence.`, + ); + } + + const doc: DeviceDoc = { + type: "MOBILE", + label: null, + platform: input.platform, + model: input.model ?? null, + appVersion: input.appVersion ?? null, + employeeId, + branchId: null, + status: "ACTIVE", + activatedAt: now, + lastSeenAt: now, + }; + tx.set(ref, { ...doc, companyId: cid, deviceId: input.deviceId, updatedAt: now }); + + return { + device: toDeviceDto(input.deviceId, doc), + deviceLimit: license.deviceLimit, + devicesInUse: inUse + 1, + seatTaken: true, + }; + }); +} + +/** + * Seats in use, counted inside the caller's transaction so the count and the + * write that depends on it cannot be separated by a concurrent activation. + */ +async function countActive( + tx: FirebaseFirestore.Transaction, + devices: FirebaseFirestore.CollectionReference, +): Promise { + const snap = await tx.get(devices.limit(1000)); + return snap.docs.filter((d) => isDeviceActive(d.data() as DeviceDoc)).length; +} + +export async function listDevices(cid: string): Promise { + const snap = await tenant(cid, "devices").limit(1000).get(); + return snap.docs + .map((d) => toDeviceDto(d.id, d.data() as DeviceDoc)) + .sort((a, b) => (b.lastSeenAt ?? "").localeCompare(a.lastSeenAt ?? "")); +} + +/** Frees the seat a device holds. Revoking an unknown device is a 404. */ +export async function setDeviceStatus( + cid: string, + deviceId: string, + status: "ACTIVE" | "REVOKED", +): Promise { + const ref = tenant(cid, "devices").doc(deviceId); + const snap = await ref.get(); + if (!snap.exists) throw ApiError.notFound("Device not found"); + + // Written to both fields so a kiosk record, which predates `status`, is + // consistently revoked whichever field a reader looks at. + await ref.set( + { status, active: status === "ACTIVE", updatedAt: nowTimestamp() }, + { merge: true }, + ); + return toDeviceDto(deviceId, { ...(snap.data() as DeviceDoc), status, active: status === "ACTIVE" }); +} diff --git a/backend/functions/src/services/notifications.integration.test.ts b/backend/functions/src/services/notifications.integration.test.ts new file mode 100644 index 0000000..78ce7ac --- /dev/null +++ b/backend/functions/src/services/notifications.integration.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { db, tenant, nowTimestamp } from "../lib/firestore"; +import { + listNotifications, + markAllRead, + markRead, + notify, + notifyAll, + unreadCount, +} from "./notifications"; + +/** + * Telling somebody something happened. + * + * The important property is not that a notification is written — it is that + * failing to write one never breaks whatever caused it. Approving leave is the + * act that matters; telling the employee is not, and an approval that gets + * refused because we could not send a message is worse in every case than an + * approval nobody was told about. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); + +let cid = ""; +let seq = 0; + +beforeEach(() => { + seq += 1; + cid = `nt_${Date.now()}_${seq}`; +}); + +afterEach(async () => { + await db.recursiveDelete(db.collection("companies").doc(cid)); + vi.restoreAllMocks(); +}); + +describe.skipIf(!EMULATOR)("notifications", () => { + it("reaches the person it is addressed to", async () => { + await notify(cid, { + employeeId: "e1", + kind: "LEAVE_DECIDED", + title: "رخصتی شما تأیید شد", + body: "از ۱۴۰۵-۰۶-۲۰ تا ۱۴۰۵-۰۶-۲۲", + link: "/leave", + }); + + const items = await listNotifications(cid, "e1"); + expect(items).toHaveLength(1); + expect(items[0].title).toBe("رخصتی شما تأیید شد"); + expect(items[0].read).toBe(false); + expect(items[0].link).toBe("/leave"); + }); + + it("does not show one person another person's news", async () => { + await notify(cid, { employeeId: "e1", kind: "PAYSLIP_READY", title: "a", body: "b" }); + + expect(await listNotifications(cid, "e2")).toEqual([]); + expect(await unreadCount(cid, "e2")).toBe(0); + }); + + it("counts only what is unread", async () => { + await notifyAll(cid, ["e1", "e1", "e2"], { kind: "PAYSLIP_READY", title: "a", body: "b" }); + + // e1 appears twice in the list and gets one notification: telling somebody + // the same thing twice is a bug, not thoroughness. + expect(await unreadCount(cid, "e1")).toBe(1); + expect(await unreadCount(cid, "e2")).toBe(1); + }); + + it("marks one as read", async () => { + await notify(cid, { employeeId: "e1", kind: "PAYSLIP_READY", title: "a", body: "b" }); + const [item] = await listNotifications(cid, "e1"); + + await markRead(cid, "e1", item.id as string); + + expect(await unreadCount(cid, "e1")).toBe(0); + expect((await listNotifications(cid, "e1"))[0].read).toBe(true); + }); + + it("refuses to mark somebody else's as read", async () => { + // A notification is addressed to a person. Marking another's read by + // guessing an id is not a thing anybody should be able to do. + await notify(cid, { employeeId: "e1", kind: "PAYSLIP_READY", title: "a", body: "b" }); + const [item] = await listNotifications(cid, "e1"); + + await expect(markRead(cid, "e2", item.id as string)).rejects.toThrow(); + expect(await unreadCount(cid, "e1")).toBe(1); + }); + + it("marks everything of the caller's, and nothing of anybody else's", async () => { + await notifyAll(cid, ["e1", "e2"], { kind: "PAYSLIP_READY", title: "a", body: "b" }); + await notify(cid, { employeeId: "e1", kind: "LEAVE_DECIDED", title: "c", body: "d" }); + + const marked = await markAllRead(cid, "e1"); + + expect(marked).toBe(2); + expect(await unreadCount(cid, "e1")).toBe(0); + expect(await unreadCount(cid, "e2")).toBe(1); + }); + + it("writes numbers in the script the text is written in", async () => { + // "دورهٔ 1405/06" among Dari prose where every other number is ۱۴۰۵ reads + // as a rendering fault — which is how it looked the first time the bell + // was opened in a browser. + await notify(cid, { + employeeId: "e1", + kind: "PAYSLIP_READY", + title: "فیش معاش شما آماده است", + body: "دورهٔ 1405/06", + }); + + const [item] = await listNotifications(cid, "e1"); + expect(item.body).toBe("دورهٔ ۱۴۰۵/۰۶"); + expect(String(item.body)).not.toMatch(/[0-9]/); + }); + + it("replaces rather than repeats when given a key", async () => { + // Payroll is deliberately re-runnable. Without this, four runs of one + // month sent every employee four identical "your payslip is ready". + for (let i = 0; i < 4; i += 1) { + await notify(cid, { + employeeId: "e1", + kind: "PAYSLIP_READY", + title: "فیش معاش شما آماده است", + body: `دورهٔ ۱۴۰۵/۰۶ — اجرای ${i}`, + dedupeKey: "payslip_1405_06", + }); + } + + const items = await listNotifications(cid, "e1"); + expect(items).toHaveLength(1); + // The last run's text, not the first: the numbers may have changed. + expect(String(items[0].body)).toContain("۳"); + }); + + it("keeps one person's keyed notification apart from another's", async () => { + await notify(cid, { employeeId: "e1", kind: "PAYSLIP_READY", title: "a", body: "b", dedupeKey: "k" }); + await notify(cid, { employeeId: "e2", kind: "PAYSLIP_READY", title: "a", body: "b", dedupeKey: "k" }); + + expect(await unreadCount(cid, "e1")).toBe(1); + expect(await unreadCount(cid, "e2")).toBe(1); + }); + + it("returns the newest first", async () => { + await notify(cid, { employeeId: "e1", kind: "PAYSLIP_READY", title: "older", body: "" }); + await new Promise((r) => setTimeout(r, 25)); + await notify(cid, { employeeId: "e1", kind: "LEAVE_DECIDED", title: "newer", body: "" }); + + const items = await listNotifications(cid, "e1"); + expect(items.map((i) => i.title)).toEqual(["newer", "older"]); + }); + + it("swallows its own failure rather than raising it at the caller", async () => { + // THE property. The caller is in the middle of approving leave, and an + // approval refused because a message could not be written is worse than an + // approval nobody was told about. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const boom = vi.spyOn(db, "collection").mockImplementation(() => { + throw new Error("firestore is having a day"); + }); + + await expect( + notify(cid, { employeeId: "e1", kind: "LEAVE_DECIDED", title: "a", body: "b" }), + ).resolves.toBeUndefined(); + + expect(warn).toHaveBeenCalled(); + boom.mockRestore(); + }); +}); + +describe.skipIf(!EMULATOR)("a leave decision tells the employee", () => { + it("writes a notification the employee can see", async () => { + // Exercised through the real service rather than by calling notify() here, + // because the thing worth testing is that the decision path calls it at + // all — that is what was missing. + const { decideLeaveRequest } = await import("./leave"); + + await db.collection("companies").doc(cid).set({ name: "N" }); + await tenant(cid, "leaveRequests").doc("r1").set({ + companyId: cid, + employeeId: "e_worker", + employeeName: "Worker", + leaveTypeId: "annual", + startDate: "1405-06-20", + endDate: "1405-06-22", + days: 3, + reason: null, + status: "PENDING", + currentApproverId: "e_admin", + createdAt: nowTimestamp(), + updatedAt: nowTimestamp(), + }); + + await decideLeaveRequest(cid, "r1", "e_admin", ["HR_ADMIN"], "REJECT", "تیم کم است"); + + const items = await listNotifications(cid, "e_worker"); + expect(items).toHaveLength(1); + expect(items[0].kind).toBe("LEAVE_DECIDED"); + expect(String(items[0].title)).toContain("رد شد"); + // The reason travels with it: "rejected" without a why sends the employee + // to find their manager, which is the conversation this replaces. + expect(items[0].body).toBe("تیم کم است"); + }); +}); diff --git a/backend/functions/src/services/notifications.ts b/backend/functions/src/services/notifications.ts new file mode 100644 index 0000000..8c52a48 --- /dev/null +++ b/backend/functions/src/services/notifications.ts @@ -0,0 +1,192 @@ +import { ApiError } from "../lib/errors"; +import { nowTimestamp, tenant, toIso } from "../lib/firestore"; +import { ulid } from "../lib/ids"; + +/** + * Telling somebody something happened. + * + * Until now nothing did. A worker did not know their leave was approved, a + * manager did not know a request was waiting, nobody knew a payslip existed — + * everyone had to open the app and go looking, which mostly meant they did not + * find out until they asked in person. + * + * --------------------------------------------------------------------------- + * A notification must NEVER break the thing that caused it. + * + * Approving leave is the important act; telling the employee about it is not. + * If the write fails — a bad index, a quota, a bug in here — the approval must + * still stand. So every function in this module swallows its own errors and + * logs them, and no caller is given anything to handle. The failure mode being + * chosen deliberately is "the approval worked and the employee was not told", + * because the alternative is "the approval was refused because we could not + * tell them", which is worse in every case. + * --------------------------------------------------------------------------- + */ + +const EASTERN = "۰۱۲۳۴۵۶۷۸۹"; + +/** + * Eastern-Arabic digits, because the text around them is Dari. + * + * The bodies here are written whole rather than assembled on the client, so + * anything numeric in them has to arrive already localised — "دورهٔ 1405/06" + * in an interface where every other number is ۱۴۰۵ reads as a rendering fault, + * which is exactly how it looked the first time it was opened. + * + * The honest limit of that choice: these strings are Dari only. Serving them + * per reader would mean storing structured data and translating at render + * time, which is worth doing when notifications reach the apps and not before. + */ +export function easternDigits(text: string): string { + return text.replace(/[0-9]/g, (d) => EASTERN[Number(d)]); +} + +export type NotificationKind = + | "LEAVE_DECIDED" + | "CORRECTION_DECIDED" + | "PAYSLIP_READY" + | "APPROVAL_WAITING"; + +export interface NotificationDoc { + employeeId: string; + kind: NotificationKind; + /** Already-translated title and body: the sender knows the tenant's language. */ + title: string; + body: string; + /** Where the app should go when it is opened. A route, not a URL. */ + link: string | null; + readAt: FirebaseFirestore.Timestamp | null; + createdAt: FirebaseFirestore.Timestamp; +} + +export function notificationToDto( + id: string, + doc: NotificationDoc, +): Record { + return { + id, + kind: doc.kind, + title: doc.title, + body: doc.body, + link: doc.link, + read: doc.readAt !== null, + createdAt: toIso(doc.createdAt), + }; +} + +/** + * Writes one notification. Never throws. + * + * See the module comment: the caller is in the middle of something that + * matters more than this. + */ +export async function notify( + cid: string, + input: { + employeeId: string; + kind: NotificationKind; + title: string; + body: string; + link?: string | null; + /** + * Makes this notification replaceable rather than repeated. + * + * Payroll is deliberately re-runnable, and without a key every re-run sent + * every employee another "your payslip is ready" — four runs of one month, + * four identical messages each. With one, the second run overwrites the + * first exactly as the payslip itself does. + * + * Unread state is deliberately reset with it: the news is new again, which + * is the honest reading when the numbers may have changed. + */ + dedupeKey?: string; + }, +): Promise { + try { + const doc: NotificationDoc = { + employeeId: input.employeeId, + kind: input.kind, + title: easternDigits(input.title), + body: easternDigits(input.body), + link: input.link ?? null, + readAt: null, + createdAt: nowTimestamp(), + }; + const id = input.dedupeKey + ? `${input.employeeId}_${input.dedupeKey}`.slice(0, 1000) + : ulid(); + await tenant(cid, "notifications").doc(id).set(doc); + } catch (error) { + // Logged, not raised. A failure here must not undo an approval. + console.warn("NOTIFY_FAILED", JSON.stringify({ cid, kind: input.kind }), error); + } +} + +/** The same, for several people at once. Also never throws. */ +export async function notifyAll( + cid: string, + employeeIds: readonly string[], + input: { + kind: NotificationKind; + title: string; + body: string; + link?: string | null; + dedupeKey?: string; + }, +): Promise { + await Promise.all([...new Set(employeeIds)].map((employeeId) => notify(cid, { employeeId, ...input }))); +} + +export async function listNotifications( + cid: string, + employeeId: string, + limit = 50, +): Promise[]> { + const snap = await tenant(cid, "notifications") + .where("employeeId", "==", employeeId) + .orderBy("createdAt", "desc") + .limit(limit) + .get(); + return snap.docs.map((d) => notificationToDto(d.id, d.data() as NotificationDoc)); +} + +export async function unreadCount(cid: string, employeeId: string): Promise { + const snap = await tenant(cid, "notifications") + .where("employeeId", "==", employeeId) + .where("readAt", "==", null) + .count() + .get(); + return snap.data().count; +} + +/** + * Marks one as read. + * + * Scoped to the caller's own id on purpose: a notification is addressed to a + * person, and marking somebody else's as read is not a thing anybody should be + * able to do by guessing an id. + */ +export async function markRead(cid: string, employeeId: string, id: string): Promise { + const ref = tenant(cid, "notifications").doc(id); + const snap = await ref.get(); + if (!snap.exists || (snap.data() as NotificationDoc).employeeId !== employeeId) { + throw ApiError.notFound("Notification not found"); + } + await ref.update({ readAt: nowTimestamp() }); +} + +/** Marks everything the caller has as read. */ +export async function markAllRead(cid: string, employeeId: string): Promise { + const snap = await tenant(cid, "notifications") + .where("employeeId", "==", employeeId) + .where("readAt", "==", null) + .limit(500) + .get(); + if (snap.empty) return 0; + + const batch = snap.docs[0].ref.firestore.batch(); + const now = nowTimestamp(); + for (const doc of snap.docs) batch.update(doc.ref, { readAt: now }); + await batch.commit(); + return snap.size; +} diff --git a/backend/functions/src/services/payModels.test.ts b/backend/functions/src/services/payModels.test.ts new file mode 100644 index 0000000..f573269 --- /dev/null +++ b/backend/functions/src/services/payModels.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import { earnedBasic, isPayModel, rateLabelKey } from "./payModels"; + +describe("a monthly salary", () => { + it("is paid whole, and absence is deducted from it afterwards", () => { + // Unchanged behaviour: this is what every existing company is on. + const r = earnedBasic({ model: "MONTHLY", rate: 30000, workedDays: 16 }); + expect(r.amount).toBe(30000); + expect(r.chargeUnpaidAbsence).toBe(true); + }); + + it("does not care how many days were worked", () => { + expect(earnedBasic({ model: "MONTHLY", rate: 30000, workedDays: 0 }).amount).toBe(30000); + expect(earnedBasic({ model: "MONTHLY", rate: 30000, workedDays: 26 }).amount).toBe(30000); + }); +}); + +describe("a daily wage", () => { + it("pays for the days that were worked", () => { + expect(earnedBasic({ model: "DAILY", rate: 700, workedDays: 20 }).amount).toBe(14000); + }); + + it("never also charges for the days that were not", () => { + // THE point of this module. A daily worker absent ten days is paid for the + // twenty they came and owes nothing for the ten. Deducting on top takes + // those days twice, and somebody absent half the month goes home with + // nothing at all. + expect(earnedBasic({ model: "DAILY", rate: 700, workedDays: 10 }).chargeUnpaidAbsence).toBe( + false, + ); + }); + + it("pays half a day for half a day", () => { + expect(earnedBasic({ model: "DAILY", rate: 700, workedDays: 15.5 }).amount).toBe(10850); + }); + + it("pays nothing for a month nobody turned up to", () => { + const r = earnedBasic({ model: "DAILY", rate: 700, workedDays: 0 }); + expect(r.amount).toBe(0); + // And still charges nothing, so the payslip is zero rather than negative. + expect(r.chargeUnpaidAbsence).toBe(false); + }); +}); + +describe("piece work", () => { + it("pays for what was finished", () => { + expect(earnedBasic({ model: "PIECE", rate: 120, workedDays: 22, pieces: 340 }).amount).toBe( + 40800, + ); + }); + + it("ignores the days entirely", () => { + // Somebody who finished in three days is owed for the work; somebody who + // sat all month and finished nothing is owed nothing. That is the bargain + // this model exists to express. + const fast = earnedBasic({ model: "PIECE", rate: 120, workedDays: 3, pieces: 340 }); + const slow = earnedBasic({ model: "PIECE", rate: 120, workedDays: 26, pieces: 340 }); + expect(fast.amount).toBe(slow.amount); + expect(earnedBasic({ model: "PIECE", rate: 120, workedDays: 26, pieces: 0 }).amount).toBe(0); + }); + + it("never charges absence either", () => { + expect( + earnedBasic({ model: "PIECE", rate: 120, workedDays: 2, pieces: 10 }).chargeUnpaidAbsence, + ).toBe(false); + }); + + it("treats a missing count as nothing finished, not as an error", () => { + expect(earnedBasic({ model: "PIECE", rate: 120, workedDays: 22 }).amount).toBe(0); + }); +}); + +describe("records that predate this", () => { + it("pays an unknown or absent model as a monthly salary", () => { + // Every existing company is monthly. The alternative to this default is a + // payroll run that refuses to pay anybody. + for (const model of [null, undefined, "", "SOMETHING_ELSE"]) { + const r = earnedBasic({ model, rate: 30000, workedDays: 16 }); + expect(r.amount, `model=${String(model)}`).toBe(30000); + expect(r.chargeUnpaidAbsence).toBe(true); + } + }); +}); + +describe("guarding the arithmetic", () => { + it("never returns a negative wage", () => { + expect(earnedBasic({ model: "DAILY", rate: -700, workedDays: 20 }).amount).toBe(0); + expect(earnedBasic({ model: "DAILY", rate: 700, workedDays: -5 }).amount).toBe(0); + }); + + it("survives a rate that is not a number", () => { + expect(earnedBasic({ model: "MONTHLY", rate: NaN, workedDays: 16 }).amount).toBe(0); + }); + + it("keeps to two decimals", () => { + expect(earnedBasic({ model: "DAILY", rate: 333.333, workedDays: 3 }).amount).toBe(1000); + }); +}); + +describe("saying what a rate is a rate for", () => { + it("labels each model differently", () => { + // "30,000" against a daily worker is a fortune and against a monthly one is + // a salary. The label is not decoration. + const keys = new Set(["MONTHLY", "DAILY", "PIECE"].map(rateLabelKey)); + expect(keys.size).toBe(3); + }); + + it("falls back to the monthly label for an unknown model", () => { + expect(rateLabelKey(null)).toBe(rateLabelKey("MONTHLY")); + }); +}); + +describe("recognising a model", () => { + it("accepts the three and nothing else", () => { + expect(isPayModel("DAILY")).toBe(true); + expect(isPayModel("daily")).toBe(false); + expect(isPayModel(null)).toBe(false); + }); +}); diff --git a/backend/functions/src/services/payModels.ts b/backend/functions/src/services/payModels.ts new file mode 100644 index 0000000..a0d1d91 --- /dev/null +++ b/backend/functions/src/services/payModels.ts @@ -0,0 +1,100 @@ +/** + * How a person is paid, as opposed to how much. + * + * Monthly salary was the only model this system had, and it does not fit the + * two kinds of business the product is most often sold to. A construction firm + * hires by the day: twenty days worked is twenty days' wage. A tailoring + * workshop pays by the piece: it does not matter how long a garment took. + * + * Everything here is pure, because it decides what a person is paid. + */ + +export type PayModel = "MONTHLY" | "DAILY" | "PIECE"; + +export const PAY_MODELS: readonly PayModel[] = ["MONTHLY", "DAILY", "PIECE"]; + +export function isPayModel(value: unknown): value is PayModel { + return typeof value === "string" && (PAY_MODELS as readonly string[]).includes(value); +} + +export interface EarnedBasic { + /** The basic pay this period actually earned. */ + amount: number; + /** + * Whether unpaid absence should ALSO be charged against this. + * + * This flag is the whole reason the module exists. A monthly salary is paid + * whole and then reduced for days not worked. A daily wage already contains + * that: a worker absent ten days is paid for the twenty they came, and + * nothing is owed for the ten. Charging loss-of-pay on top would take those + * ten days off TWICE — once by not paying them, once by deducting them — and + * a worker absent half a month would go home with nothing. + */ + chargeUnpaidAbsence: boolean; +} + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +/** + * What the basic pay comes to, and whether absence is still to be deducted. + * + * `rate` means whatever the model says it means — a monthly salary, a day's + * wage, or the price of one piece — which is why it is stored in one field and + * read through here rather than being three fields somebody has to keep + * consistent. + * + * An unknown model is treated as MONTHLY: a company whose record predates this + * is on a monthly salary, and the alternative is a payroll run that refuses to + * pay anybody. + */ +export function earnedBasic(params: { + model: PayModel | string | null | undefined; + rate: number; + /** Days actually worked, halves included. */ + workedDays: number; + /** Pieces completed in the period. Ignored unless the model is PIECE. */ + pieces?: number; +}): EarnedBasic { + const rate = Number.isFinite(params.rate) ? Math.max(0, params.rate) : 0; + + switch (params.model) { + case "DAILY": + return { + amount: round2(rate * Math.max(0, params.workedDays)), + chargeUnpaidAbsence: false, + }; + + case "PIECE": + // Days are irrelevant here on purpose. Somebody who finished the work in + // three days is owed for the work, and somebody who sat all month and + // finished nothing is owed nothing — which is what the company agreed to + // and what makes this model worth having. + return { + amount: round2(rate * Math.max(0, params.pieces ?? 0)), + chargeUnpaidAbsence: false, + }; + + default: + return { amount: round2(rate), chargeUnpaidAbsence: true }; + } +} + +/** + * What to call the rate on screen, so a number is never shown without saying + * what it is a rate FOR. + * + * "30,000" against a daily worker is a fortune; against a monthly one it is a + * salary. The label is not decoration. + */ +export function rateLabelKey(model: PayModel | string | null | undefined): string { + switch (model) { + case "DAILY": + return "pay_rate_daily"; + case "PIECE": + return "pay_rate_piece"; + default: + return "pay_rate_monthly"; + } +} diff --git a/backend/functions/src/services/payroll.integration.test.ts b/backend/functions/src/services/payroll.integration.test.ts new file mode 100644 index 0000000..8f829b6 --- /dev/null +++ b/backend/functions/src/services/payroll.integration.test.ts @@ -0,0 +1,1117 @@ +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { Timestamp } from "firebase-admin/firestore"; +import { db, nowTimestamp, tenant } from "../lib/firestore"; +import { computePayrollRun } from "./payroll"; +import { currentShamsiMonth, shamsiMonthStartIso } from "../lib/shamsi"; +import { localDateOf } from "./attendance"; + +/** + * Payroll's engine was correct all along; nothing could feed it. employeeSalaries + * had no write path outside the demo seed, and an employee with no salary on file + * is skipped silently — so a real company got 200 with payslipCount 0 and a + * portal that reported a successful, empty run. + * + * Skipped unless a Firestore emulator is running. + */ + +const EMULATOR = Boolean(process.env.FIRESTORE_EMULATOR_HOST); +let cid = ""; +let seq = 0; + +async function employee(id: string): Promise { + await tenant(cid, "employees").doc(id).set({ + employeeCode: id, + firstName: "Test", + lastName: id, + status: "ACTIVE", + updatedAt: Timestamp.now(), + }); +} + +async function salary(employeeId: string, basicAmount: number): Promise { + await tenant(cid, "employeeSalaries").doc(employeeId).set({ + employeeId, + structureId: null, + basicAmount, + currency: "AFN", + effectiveFrom: "2026-01-01", + revisionReason: "Initial", + updatedAt: Timestamp.now(), + }); +} + +async function component( + id: string, + fields: Record, +): Promise { + await tenant(cid, "salaryComponents").doc(id).set({ + companyId: cid, + calc: "FIXED", + active: true, + ...fields, + }); +} + +async function assign( + employeeId: string, + componentId: string, + fields: Record = {}, +): Promise { + await tenant(cid, "employeeComponents").doc(`${employeeId}__${componentId}`).set({ + companyId: cid, + employeeId, + componentId, + value: null, + active: true, + ...fields, + }); +} + +const round = (n: number) => Math.round(n * 100) / 100; + +/** Unpaid-absence days inside Shamsi 1405/05 (2026-07-23 .. 2026-08-22). */ +async function unpaidDays(employeeId: string, count: number): Promise { + const start = new Date("2026-07-23T00:00:00Z").getTime(); + for (let i = 0; i < count; i++) { + const date = new Date(start + i * 86_400_000).toISOString().slice(0, 10); + await tenant(cid, "attendanceDays").doc(`${employeeId}_${date}`).set({ + employeeId, + date, + status: "PENDING", + workedMinutes: 0, + }); + } +} + +/** + * Marks every working day of Shamsi 1405/05 present, except the dates given. + * + * Payroll now walks the working calendar, so a test that only wants to exercise + * the tax brackets or the ledger has to say the person turned up — otherwise + * they read as absent for the whole month and earn nothing. + */ +async function attendAll(employeeId: string, except: string[] = []): Promise { + const skip = new Set(except); + const end = new Date("2026-08-22T00:00:00Z").getTime(); + for (let t = new Date("2026-07-23T00:00:00Z").getTime(); t <= end; t += 86_400_000) { + const d = new Date(t); + if (d.getUTCDay() === 5) continue; // Friday: not a working day + const date = d.toISOString().slice(0, 10); + if (skip.has(date)) continue; + await tenant(cid, "attendanceDays").doc(`${employeeId}_${date}`).set({ + employeeId, date, status: "PRESENT", workedMinutes: 480, + }); + } +} + +async function payrollEntries(): Promise[]> { + const snap = await tenant(cid, "journalEntries").where("source", "==", "PAYROLL").get(); + return snap.docs.map((d) => d.data() as Record); +} + +interface Line { accountCode: string; debit: number; credit: number } + +function creditOf(entry: Record, code: string): number { + const lines = (entry.lines as Line[]) ?? []; + return lines.filter((l) => l.accountCode === code).reduce((s, l) => s + l.credit, 0); +} + +function debitOf(entry: Record, code: string): number { + const lines = (entry.lines as Line[]) ?? []; + return lines.filter((l) => l.accountCode === code).reduce((s, l) => s + l.debit, 0); +} + +describe.skipIf(!EMULATOR)("payroll run", () => { + beforeEach(async () => { + cid = `pr_${Date.now()}_${seq++}`; + await db.collection("companies").doc(cid).set({ + name: "Payroll", + timezone: "Asia/Kabul", + settings: { profile: { currency: "AFN", timezone: "Asia/Kabul" } }, + }); + }); + + it("produces nothing for an employee with no salary on file", async () => { + await employee("e1"); + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + // This is the state a self-signed-up company was permanently in. + expect(run.payslipCount).toBe(0); + }); + + it("produces a payslip once a salary is configured", async () => { + await employee("e1"); + await salary("e1", 30000); + await attendAll("e1"); + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(run.payslipCount).toBe(1); + expect(run.totalGross).toBe(30000); + expect(run.totalNet).toBeGreaterThan(0); + expect(run.totalNet).toBeLessThanOrEqual(30000); + }); + + it("adds an earning component to gross", async () => { + await employee("e1"); + await salary("e1", 30000); + await tenant(cid, "salaryComponents").doc("c1").set({ + companyId: cid, + name: "Transport", + code: "TRANSPORT", + type: "EARNING", + calc: "FIXED", + value: 3000, + taxable: false, + active: true, + }); + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(run.payslipCount).toBe(1); + expect(run.totalGross).toBe(33000); + }); + + it("ignores an inactive component", async () => { + await employee("e1"); + await salary("e1", 30000); + await tenant(cid, "salaryComponents").doc("c1").set({ + companyId: cid, + name: "Old bonus", + code: "OLD", + type: "EARNING", + calc: "FIXED", + value: 5000, + active: false, + }); + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(run.totalGross).toBe(30000); + }); + + it("pays only the employees who have a salary", async () => { + await employee("e1"); + await employee("e2"); + await salary("e1", 30000); + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(run.payslipCount).toBe(1); + }); +}); + +/** + * Income tax was computed on gross before the loss-of-pay charge was applied, + * so an employee was taxed on pay they never received; and the `taxable` flag + * the components API writes was never read, so an exempt allowance was taxed + * anyway. Loss of pay was also uncapped, which drove net pay negative. + */ +describe.skipIf(!EMULATOR)("payroll — income tax base", () => { + beforeEach(async () => { + cid = `tax_${Date.now()}_${seq++}`; + await db.collection("companies").doc(cid).set({ + name: "Tax", + timezone: "Asia/Kabul", + settings: { profile: { currency: "AFN", timezone: "Asia/Kabul" } }, + }); + }); + + it("does not tax pay lost to unpaid absence", async () => { + await employee("e1"); + await salary("e1", 30000); + const missed = ["2026-07-23", "2026-07-27", "2026-07-28"]; + await attendAll("e1", missed); + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + // 26 working days, 3 missed: 30000/26 * 3 = 3461.54 docked, leaving + // 26538.46 earned. Tax = 150 + 10% of (26538.46 - 12500) = 1553.85. + // Taxing the full 30000 gross would give 1900. + expect(run.totalTax).toBe(1553.85); + }); + + it("leaves an exempt allowance out of the tax base", async () => { + await employee("e1"); + await salary("e1", 30000); + await component("c1", { + name: "Transport", + code: "TRANSPORT", + type: "EARNING", + value: 3000, + taxable: false, + }); + await attendAll("e1"); + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(run.totalGross).toBe(33000); // still paid + expect(run.totalTax).toBe(1900); // but taxed on 30000, not 33000 + }); + + it("taxes an allowance that is marked taxable", async () => { + await employee("e1"); + await salary("e1", 30000); + await component("c1", { + name: "Bonus", + code: "BONUS", + type: "EARNING", + value: 3000, + taxable: true, + }); + await attendAll("e1"); + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(run.totalTax).toBe(2200); // 150 + 10% of (33000 - 12500) + }); + + it("caps loss of pay at gross so net pay never goes negative", async () => { + await employee("e1"); + await salary("e1", 30000); + // No attendance at all: every working day is unexcused absence. + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(run.totalNet).toBe(0); + expect(run.totalTax).toBe(0); // nothing was earned to tax + }); +}); + +/** + * The ledger accrual credited Salaries Payable with gross − tax, which is not + * what the company owes its employees: it overstated the liability by the + * loss-of-pay charge and by every non-tax deduction, and overstated salary + * expense to match. The entry balanced, so nothing caught it. + */ +describe.skipIf(!EMULATOR)("payroll — general ledger accrual", () => { + beforeEach(async () => { + cid = `gl_${Date.now()}_${seq++}`; + await db.collection("companies").doc(cid).set({ + name: "Ledger", + timezone: "Asia/Kabul", + settings: { profile: { currency: "AFN", timezone: "Asia/Kabul" } }, + }); + }); + + it("credits each liability with what is actually owed", async () => { + await employee("e1"); + await salary("e1", 30000); + await component("d1", { name: "Advance", code: "ADV", type: "DEDUCTION", value: 2000 }); + await component("p1", { + name: "Pension", + code: "PENSION", + type: "EMPLOYER_COST", + value: 1500, + }); + await attendAll("e1"); + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + // gross 30000, tax on 30000 = 1900, advance 2000 → net 26100. + expect(run.totalNet).toBe(26100); + + const entries = await payrollEntries(); + expect(entries).toHaveLength(1); + const entry = entries[0]; + + // Salaries Payable is take-home pay — not gross − tax (which was 29600). + expect(creditOf(entry, "2100")).toBe(26100); + expect(creditOf(entry, "2200")).toBe(1900); // tax withheld + expect(creditOf(entry, "2300")).toBe(2000); // advance withheld + expect(creditOf(entry, "2400")).toBe(1500); // employer contribution + expect(debitOf(entry, "5000")).toBe(31500); // and expense equals the credits + }); + + it("posts a balanced entry", async () => { + await employee("e1"); + await salary("e1", 30000); + await unpaidDays("e1", 4); + await component("d1", { name: "Advance", code: "ADV", type: "DEDUCTION", value: 1000 }); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + const [entry] = await payrollEntries(); + const lines = (entry.lines as Line[]) ?? []; + const debit = lines.reduce((s, l) => s + l.debit, 0); + const credit = lines.reduce((s, l) => s + l.credit, 0); + expect(debit).toBe(credit); + }); + + it("posts to accounts that exist in the chart", async () => { + // A company seeded before these codes existed would otherwise accrue to + // accounts the trial balance cannot resolve, and silently unbalance it. + await employee("e1"); + await salary("e1", 30000); + await component("d1", { name: "Advance", code: "ADV", type: "DEDUCTION", value: 1000 }); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + const [entry] = await payrollEntries(); + const codes = ((entry.lines as Line[]) ?? []).map((l) => l.accountCode); + for (const code of codes) { + const account = await tenant(cid, "accounts").doc(code).get(); + expect(account.exists, `account ${code} missing from the chart`).toBe(true); + } + }); + + it("does not accrue the month twice when the run is repeated", async () => { + await employee("e1"); + await salary("e1", 30000); + // Somebody has to be owed something, or there is nothing to accrue. + await attendAll("e1"); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(await payrollEntries()).toHaveLength(1); + }); +}); + +/** + * Payroll used to iterate the attendanceDays documents it found. Those exist + * only when something happened, so an employee who never came to work produced + * none and was paid in full — while Friday and Eid, which also produce none, + * were indistinguishable from that truancy. + * + * It now walks the working calendar: weekends and holidays are excluded, and a + * working day with no record is unexcused absence. + */ +describe.skipIf(!EMULATOR)("payroll — the working calendar", () => { + beforeEach(async () => { + cid = `cal_${Date.now()}_${seq++}`; + await db.collection("companies").doc(cid).set({ + name: "Calendar", + timezone: "Asia/Kabul", + settings: { + profile: { currency: "AFN", timezone: "Asia/Kabul" }, + policies: { standardDailyMinutes: 480, weekendDays: [5], lateGraceMinutes: 10, overtimeEnabled: true }, + }, + }); + }); + + async function holiday(date: string, name: string): Promise { + await tenant(cid, "holidays").doc(date).set({ date, name, nameEn: name, paid: true, source: "MANUAL" }); + } + + /** Marks a day present so it is not counted as absence. */ + async function present(employeeId: string, date: string): Promise { + await tenant(cid, "attendanceDays").doc(`${employeeId}_${date}`).set({ + employeeId, date, status: "PRESENT", workedMinutes: 480, + }); + } + + it("docks an employee who never turned up at all", async () => { + await employee("e1"); + await salary("e1", 30000); + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + // Shamsi 1405/05 is 2026-07-23..08-22: 31 days containing 5 Fridays, so 26 + // working days, none of them attended. A fully absent month costs the month. + expect(run.payslipCount).toBe(1); + expect(run.totalNet).toBe(0); + }); + + it("names the employees it could not pay instead of dropping them", async () => { + // The first run a new company does is the one most likely to have people + // with no salary yet. Omitting them silently makes the run look complete. + await employee("e1"); + await salary("e1", 30000); + await employee("e2"); // hired, no salary configured + for (const d of eachWorkingDay()) { + await present("e1", d); + await present("e2", d); + } + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(run.payslipCount).toBe(1); + expect(run.skippedNoSalary.map((s) => s.employeeId)).toEqual(["e2"]); + }); + + it("flags someone who worked this month and was then marked as having left", async () => { + // Payroll only pays ACTIVE employees. Marking a leaver EXITED before the + // final run therefore erases their last month's pay in silence. + await employee("e1"); + await salary("e1", 30000); + await employee("e_gone"); + await salary("e_gone", 20000); + for (const d of eachWorkingDay()) { + await present("e1", d); + await present("e_gone", d); + } + await tenant(cid, "employees").doc("e_gone").set({ status: "EXITED" }, { merge: true }); + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(run.payslipCount).toBe(1); + expect(run.skippedExited.map((s) => s.employeeId)).toEqual(["e_gone"]); + }); + + it("does not flag a leaver who did not work in the period", async () => { + await employee("e1"); + await salary("e1", 30000); + for (const d of eachWorkingDay()) await present("e1", d); + await employee("e_old"); + await tenant(cid, "employees").doc("e_old").set({ status: "EXITED" }, { merge: true }); + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + expect(run.skippedExited).toEqual([]); + }); + + it("pays an individual allowance only to the employee it is assigned to", async () => { + await employee("e1"); + await salary("e1", 30000); + await employee("e2"); + await salary("e2", 30000); + for (const d of eachWorkingDay()) { + await present("e1", d); + await present("e2", d); + } + await component("bonus", { + name: "Site bonus", + code: "SITE", + type: "EARNING", + value: 5000, + scope: "INDIVIDUAL", + taxable: true, + }); + await assign("e1", "bonus"); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + const one = (await tenant(cid, "payslips").doc("e1_1405_05").get()).data()!; + const two = (await tenant(cid, "payslips").doc("e2_1405_05").get()).data()!; + expect(one.gross).toBe(35000); + expect(two.gross).toBe(30000); + expect((one.lines as Array<{ componentCode: string }>).map((l) => l.componentCode)).toContain( + "SITE", + ); + expect((two.lines as Array<{ componentCode: string }>).map((l) => l.componentCode)).not.toContain( + "SITE", + ); + }); + + it("pays one employee a different amount for the same allowance", async () => { + await employee("e1"); + await salary("e1", 30000); + await employee("e2"); + await salary("e2", 30000); + for (const d of eachWorkingDay()) { + await present("e1", d); + await present("e2", d); + } + await component("transport", { + name: "Transport", + code: "TRANSPORT", + type: "EARNING", + value: 2000, + scope: "ALL", + taxable: true, + }); + await assign("e1", "transport", { value: 3500 }); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect((await tenant(cid, "payslips").doc("e1_1405_05").get()).data()!.gross).toBe(33500); + expect((await tenant(cid, "payslips").doc("e2_1405_05").get()).data()!.gross).toBe(32000); + }); + + it("withholds a company-wide allowance from one employee", async () => { + await employee("e1"); + await salary("e1", 30000); + await employee("e2"); + await salary("e2", 30000); + for (const d of eachWorkingDay()) { + await present("e1", d); + await present("e2", d); + } + await component("transport", { + name: "Transport", + code: "TRANSPORT", + type: "EARNING", + value: 2000, + scope: "ALL", + taxable: true, + }); + await assign("e1", "transport", { active: false }); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect((await tenant(cid, "payslips").doc("e1_1405_05").get()).data()!.gross).toBe(30000); + expect((await tenant(cid, "payslips").doc("e2_1405_05").get()).data()!.gross).toBe(32000); + }); + + it("keeps paying a component written before scope existed to everyone", async () => { + // The migration case: nobody's pay may change because the field was added. + await employee("e1"); + await salary("e1", 30000); + for (const d of eachWorkingDay()) await present("e1", d); + await component("old", { + name: "Old allowance", + code: "OLD", + type: "EARNING", + value: 1000, + taxable: true, + // deliberately no scope + }); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect((await tenant(cid, "payslips").doc("e1_1405_05").get()).data()!.gross).toBe(31000); + }); + + it("applies an individual deduction to one person only", async () => { + await employee("e1"); + await salary("e1", 30000); + await employee("e2"); + await salary("e2", 30000); + for (const d of eachWorkingDay()) { + await present("e1", d); + await present("e2", d); + } + await component("loan", { + name: "Loan repayment", + code: "LOAN", + type: "DEDUCTION", + value: 1500, + scope: "INDIVIDUAL", + }); + await assign("e2", "loan"); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + const one = (await tenant(cid, "payslips").doc("e1_1405_05").get()).data()!; + const two = (await tenant(cid, "payslips").doc("e2_1405_05").get()).data()!; + expect(one.totalDeductions).toBe(one.incomeTax); + expect(two.totalDeductions).toBe(round(two.incomeTax + 1500)); + }); + + it("keeps an untaxed individual allowance out of the tax base", async () => { + await employee("e1"); + await salary("e1", 30000); + for (const d of eachWorkingDay()) await present("e1", d); + await component("relief", { + name: "Hardship", + code: "RELIEF", + type: "EARNING", + value: 4000, + scope: "INDIVIDUAL", + taxable: false, + }); + await assign("e1", "relief"); + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + // Gross rises by the allowance; the tax does not, because it is exempt. + const slip = (await tenant(cid, "payslips").doc("e1_1405_05").get()).data()!; + expect(slip.gross).toBe(34000); + expect(run.totalTax).toBe(1900); // the tax on 30000 alone + }); + + it("does not accrue a still-running month on a date that has not arrived", async () => { + // Dating an in-progress run at the period end puts the whole salary cost + // in the future, and every trend built on the ledger follows it there. + await employee("e1"); + await salary("e1", 30000); + + const { year, month } = currentShamsiMonth(); + await computePayrollRun(cid, year, month, "admin", "AFN"); + + const entry = ( + await tenant(cid, "journalEntries").doc(`PAYROLL_${year}_${String(month).padStart(2, "0")}`).get() + ).data()!; + const today = localDateOf(new Date(), "Asia/Kabul"); + expect(entry.date as string <= today).toBe(true); + }); + + it("accrues a finished month on its last day", async () => { + await employee("e1"); + await salary("e1", 30000); + for (const d of eachWorkingDay()) await present("e1", d); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + const entry = (await tenant(cid, "journalEntries").doc("PAYROLL_1405_05").get()).data()!; + expect(entry.date).toBe("2026-08-22"); // last day of Shamsi 1405/05 + }); + + it("does not dock days that have not happened yet", async () => { + // Payroll walks the month's expected working days, so running the month + // that is still in progress used to charge every day from today to the end + // of the month as unexcused absence: an employee with a clean record was + // issued a FINALIZED payslip for roughly half their salary. A day in the + // future is neither worked nor absent — it has not happened. + await employee("e1"); + await salary("e1", 30000); + + const { year, month } = currentShamsiMonth(); + const today = localDateOf(new Date(), "Asia/Kabul"); + for ( + let t = new Date(`${shamsiMonthStartIso(year, month)}T00:00:00Z`).getTime(); + t <= new Date(`${today}T00:00:00Z`).getTime(); + t += 86_400_000 + ) { + const d = new Date(t); + if (d.getUTCDay() === 5) continue; // Friday + await present("e1", d.toISOString().slice(0, 10)); + } + + const run = await computePayrollRun(cid, year, month, "admin", "AFN"); + const slip = ( + await tenant(cid, "payslips") + .doc(`e1_${year}_${String(month).padStart(2, "0")}`) + .get() + ).data()!; + + // Every elapsed working day was attended, so nothing is owed back — and the + // rest of the month must not be counted against them. + expect(slip.lopDays).toBe(0); + // Full month's pay less income tax — the same figure a clean, completed + // month produces, not a fraction of it. + expect(slip.net).toBe(30000 - slip.incomeTax); + }); + + it("pays in full when every working day is attended", async () => { + await employee("e1"); + await salary("e1", 30000); + for (const d of eachWorkingDay()) await present("e1", d); + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + // Gross less income tax only — no loss of pay at all. + expect(run.totalNet).toBe(30000 - run.totalTax); + }); + + it("does not dock anyone for Friday", async () => { + await employee("e1"); + await salary("e1", 30000); + for (const d of eachWorkingDay()) await present("e1", d); + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + const slip = (await tenant(cid, "payslips").get()).docs[0].data(); + + expect(slip.lopDays).toBe(0); + }); + + it("does not dock anyone for a public holiday", async () => { + await employee("e1"); + await salary("e1", 30000); + // Close a working Tuesday and attend everything else. + await holiday("2026-08-04", "عید"); + for (const d of eachWorkingDay()) { + if (d !== "2026-08-04") await present("e1", d); + } + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + const slip = (await tenant(cid, "payslips").get()).docs[0].data(); + + expect(slip.lopDays).toBe(0); + expect(run.totalNet).toBe(30000 - run.totalTax); + }); + + it("still docks the one day someone missed", async () => { + await employee("e1"); + await salary("e1", 30000); + const days = eachWorkingDay(); + for (const d of days.slice(1)) await present("e1", d); + + const slipBefore = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + const slip = (await tenant(cid, "payslips").get()).docs[0].data(); + + expect(slip.lopDays).toBe(1); + expect(slipBefore.totalNet).toBeLessThan(30000); + }); + + it("honours a company that rests on Sunday instead", async () => { + await db.collection("companies").doc(cid).set( + { settings: { policies: { weekendDays: [7] } } }, + { merge: true }, + ); + await employee("e1"); + await salary("e1", 30000); + // Attend every day that is not a Sunday. + for (const d of allDates()) { + if (new Date(`${d}T00:00:00Z`).getUTCDay() !== 0) await present("e1", d); + } + + const run = await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + const slip = (await tenant(cid, "payslips").get()).docs[0].data(); + + expect(slip.lopDays).toBe(0); + expect(run.totalNet).toBe(30000 - run.totalTax); + }); +}); + +/** Every date in Shamsi 1405/05. */ +function allDates(): string[] { + const out: string[] = []; + const end = new Date("2026-08-22T00:00:00Z").getTime(); + for (let t = new Date("2026-07-23T00:00:00Z").getTime(); t <= end; t += 86_400_000) { + out.push(new Date(t).toISOString().slice(0, 10)); + } + return out; +} + +/** Those of them that are not a Friday. */ +function eachWorkingDay(): string[] { + return allDates().filter((d) => new Date(`${d}T00:00:00Z`).getUTCDay() !== 5); +} + +/** + * Advances, taken back out of the pay they were an advance on. + * + * The arithmetic is pinned in services/advances.test.ts. What only a real run + * can show is whether the money comes out once — payroll is deliberately + * recomputable, and a repayment that simply subtracted from a balance would + * take the same month twice. + */ +describe.skipIf(!EMULATOR)("salary advances", () => { + beforeEach(async () => { + cid = `adv_${Date.now()}_${seq++}`; + await db.collection("companies").doc(cid).set({ + name: "Advances", + timezone: "Asia/Kabul", + settings: { profile: { currency: "AFN", timezone: "Asia/Kabul" } }, + }); + }); + + async function advance( + id: string, + employeeId: string, + principal: number, + instalment: number | null = null, + ): Promise { + await tenant(cid, "advances").doc(id).set({ + employeeId, + employeeName: employeeId, + principal, + instalment, + issuedOn: "2026-08-01", + note: null, + repaid: 0, + status: "OUTSTANDING", + createdBy: "admin", + createdAt: nowTimestamp(), + updatedAt: nowTimestamp(), + }); + } + + async function payslipOf(employeeId: string): Promise> { + const snap = await tenant(cid, "payslips").doc(`${employeeId}_1405_05`).get(); + return snap.data() as Record; + } + + function advanceLine(payslip: Record): number { + return (payslip.lines as { componentCode: string; amount: number }[]) + .filter((l) => l.componentCode === "ADVANCE") + .reduce((s, l) => s + l.amount, 0); + } + + async function outstandingOf(id: string): Promise { + const doc = (await tenant(cid, "advances").doc(id).get()).data() as any; + return Math.round((doc.principal - doc.repaid) * 100) / 100; + } + + it("takes the advance out of the payslip", async () => { + await employee("e1"); + await salary("e1", 30000); + await attendAll("e1"); + await advance("a1", "e1", 5000); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + const slip = await payslipOf("e1"); + expect(advanceLine(slip)).toBe(5000); + expect(await outstandingOf("a1")).toBe(0); + }); + + it("does not take it twice when the month is run again", async () => { + // Payroll is recomputable by design — payslip ids and the journal entry are + // derived from the run. An advance that mutated a balance would be taken + // once per run of the same month, and the worker would be paid less every + // time somebody corrected an attendance record. + await employee("e1"); + await salary("e1", 30000); + await attendAll("e1"); + await advance("a1", "e1", 5000); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + const firstNet = (await payslipOf("e1")).net; + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + const slip = await payslipOf("e1"); + expect(advanceLine(slip)).toBe(5000); + expect(slip.net).toBe(firstNet); + expect(await outstandingOf("a1")).toBe(0); + }); + + it("takes an instalment and leaves the rest owing", async () => { + await employee("e1"); + await salary("e1", 30000); + await attendAll("e1"); + await advance("a1", "e1", 12000, 3000); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(advanceLine(await payslipOf("e1"))).toBe(3000); + expect(await outstandingOf("a1")).toBe(9000); + }); + + it("never pushes a payslip below zero, and carries the rest", async () => { + // Somebody who was absent most of the month owes more than the month pays. + await employee("e1"); + await salary("e1", 30000); + await unpaidDays("e1", 30); + await advance("a1", "e1", 40000); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + const slip = await payslipOf("e1"); + expect(slip.net).toBeGreaterThanOrEqual(0); + // Whatever was taken, the debt fell by exactly that and no more. + expect(await outstandingOf("a1")).toBe(Math.round((40000 - advanceLine(slip)) * 100) / 100); + }); + + it("gives way to tax rather than the other way round", async () => { + // An advance is the company's own money coming back; tax is owed to the + // state on what was earned. When there is not enough for both, the debt to + // the employer is what yields. + await employee("e1"); + await salary("e1", 30000); + await attendAll("e1"); + await advance("a1", "e1", 999999); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + const slip = await payslipOf("e1"); + const tax = (slip.lines as { componentCode: string; amount: number }[]).find( + (l) => l.componentCode === "TAX", + ); + expect(tax?.amount).toBeGreaterThan(0); + expect(slip.net).toBe(0); + }); + + it("repays the oldest debt first, across two advances", async () => { + await employee("e1"); + await salary("e1", 30000); + await attendAll("e1"); + await tenant(cid, "advances").doc("older").set({ + employeeId: "e1", employeeName: "e1", principal: 2000, instalment: null, + issuedOn: "2026-07-01", note: null, repaid: 0, status: "OUTSTANDING", + createdBy: "admin", createdAt: nowTimestamp(), updatedAt: nowTimestamp(), + }); + await advance("newer", "e1", 3000); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(advanceLine(await payslipOf("e1"))).toBe(5000); + expect(await outstandingOf("older")).toBe(0); + expect(await outstandingOf("newer")).toBe(0); + }); + + it("leaves one employee's debt off another's payslip", async () => { + await employee("e1"); + await employee("e2"); + await salary("e1", 30000); + await salary("e2", 30000); + await attendAll("e1"); + await attendAll("e2"); + await advance("a1", "e1", 5000); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(advanceLine(await payslipOf("e1"))).toBe(5000); + expect(advanceLine(await payslipOf("e2"))).toBe(0); + }); + + it("ignores an advance that was cancelled", async () => { + await employee("e1"); + await salary("e1", 30000); + await attendAll("e1"); + await advance("a1", "e1", 5000); + await tenant(cid, "advances").doc("a1").update({ status: "CANCELLED" }); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(advanceLine(await payslipOf("e1"))).toBe(0); + }); +}); + +/** + * Paying by the day and by the piece. + * + * The arithmetic is pinned in services/payModels.test.ts. What only a real run + * can show is whether the loss-of-pay charge is correctly SUPPRESSED for these + * models — the failure being guarded against pays a daily worker for the days + * they came and then deducts the days they did not, taking the absence twice. + */ +/** Every working day of the period before August — the first half of it. */ +const DAYS_BEFORE_AUGUST: string[] = (() => { + const out: string[] = []; + const end = new Date("2026-07-31T00:00:00Z").getTime(); + for (let t = new Date("2026-07-23T00:00:00Z").getTime(); t <= end; t += 86_400_000) { + out.push(new Date(t).toISOString().slice(0, 10)); + } + return out; +})(); + +describe.skipIf(!EMULATOR)("pay models", () => { + beforeEach(async () => { + cid = `pm_${Date.now()}_${seq++}`; + await db.collection("companies").doc(cid).set({ + name: "Pay models", + timezone: "Asia/Kabul", + settings: { profile: { currency: "AFN", timezone: "Asia/Kabul" } }, + }); + }); + + async function salaryWithModel( + employeeId: string, + amount: number, + payModel: string, + ): Promise { + await tenant(cid, "employeeSalaries").doc(employeeId).set({ + employeeId, + structureId: null, + basicAmount: amount, + payModel, + currency: "AFN", + effectiveFrom: "1405-01-01", + updatedAt: nowTimestamp(), + }); + } + + async function pieces(employeeId: string, date: string, quantity: number): Promise { + await tenant(cid, "pieceRecords").doc(`${employeeId}_${date}`).set({ + employeeId, + employeeName: employeeId, + date, + quantity, + note: null, + recordedBy: "admin", + createdAt: nowTimestamp(), + }); + } + + async function slip(employeeId: string): Promise> { + return (await tenant(cid, "payslips").doc(`${employeeId}_1405_05`).get()).data() as any; + } + + function line(p: Record, code: string): number { + return (p.lines as { componentCode: string; amount: number }[]) + .filter((l) => l.componentCode === code) + .reduce((s, l) => s + l.amount, 0); + } + + it("pays a daily worker for the days they came", async () => { + await employee("e1"); + await salaryWithModel("e1", 700, "DAILY"); + await attendAll("e1"); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + const p = await slip("e1"); + // Every elapsed working day was attended, so basic is 700 × those days. + expect(line(p, "BASIC")).toBe(700 * p.workedDays); + }); + + it("never charges a daily worker for the days they did not", async () => { + // The failure this whole module exists to prevent. PARTIAL attendance is + // what shows it: a first attempt used a worker absent the whole month, and + // the test passed either way — with nothing earned there is nothing to + // deduct, because loss of pay is capped at gross. So this one comes in for + // the second half of the month and stays away for the first. + await employee("e1"); + await salaryWithModel("e1", 700, "DAILY"); + await attendAll("e1", DAYS_BEFORE_AUGUST); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + const p = await slip("e1"); + expect(p.workedDays).toBeGreaterThan(0); + expect(p.lopDays).toBeGreaterThan(0); // the absence IS recorded… + expect(line(p, "LOP")).toBe(0); // …but never charged. + // The wage is exactly the days worked. Asserted on BASIC rather than on + // net, because net is also net of income tax and conflating the two hides + // which of them moved. + expect(line(p, "BASIC")).toBe(700 * p.workedDays); + expect(p.gross).toBe(700 * p.workedDays); + }); + + it("still charges a monthly employee for absence", async () => { + // The behaviour every existing company is on must not have moved. + await employee("e1"); + await salaryWithModel("e1", 30000, "MONTHLY"); + await unpaidDays("e1", 30); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(line(await slip("e1"), "LOP")).toBeGreaterThan(0); + }); + + it("treats a salary with no model on it as monthly", async () => { + // Every record written before today. Paying these by the day would divide + // a month's salary across each day worked and multiply somebody's wage. + await employee("e1"); + await salary("e1", 30000); + await attendAll("e1"); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(line(await slip("e1"), "BASIC")).toBe(30000); + }); + + it("pays piece work for what was finished, not for the time it took", async () => { + await employee("e1"); + await salaryWithModel("e1", 120, "PIECE"); + await attendAll("e1"); + await pieces("e1", shamsiMonthStartIso(1405, 5), 200); + // Month 5 of 1405 is 2026-07-23 to 2026-08-22, so a date in June is + // genuinely outside it. My first attempt used 2026-08-20, which is INSIDE + // — the test failed and the code was right. + await pieces("e1", "2026-06-15", 140); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + const p = await slip("e1"); + // Only the record inside the period counts; June belongs to another month. + expect(line(p, "BASIC")).toBe(120 * 200); + expect(line(p, "LOP")).toBe(0); + }); + + it("pays a piece worker nothing when nothing was finished", async () => { + await employee("e1"); + await salaryWithModel("e1", 120, "PIECE"); + await attendAll("e1"); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + const p = await slip("e1"); + expect(line(p, "BASIC")).toBe(0); + expect(p.net).toBe(0); + }); + + it("does not let one worker's pieces reach another's payslip", async () => { + await employee("e1"); + await employee("e2"); + await salaryWithModel("e1", 120, "PIECE"); + await salaryWithModel("e2", 120, "PIECE"); + await attendAll("e1"); + await attendAll("e2"); + await pieces("e1", shamsiMonthStartIso(1405, 5), 200); + + await computePayrollRun(cid, 1405, 5, "admin", "AFN"); + + expect(line(await slip("e1"), "BASIC")).toBe(24000); + expect(line(await slip("e2"), "BASIC")).toBe(0); + }); + + afterEach(async () => { + await db.recursiveDelete(db.collection("companies").doc(cid)); + }); +}); diff --git a/backend/functions/src/services/payroll.ts b/backend/functions/src/services/payroll.ts new file mode 100644 index 0000000..50510a8 --- /dev/null +++ b/backend/functions/src/services/payroll.ts @@ -0,0 +1,541 @@ +import { nowTimestamp, tenant } from "../lib/firestore"; +import { shamsiMonthEndIso, shamsiMonthStartIso } from "../lib/shamsi"; +import { ensureAccounts, postJournalEntry } from "./accounting"; +import { planRepayments } from "./advances"; +import { outstandingFor, reconcileRepayments } from "./advanceStore"; +import { localDateOf } from "./attendance"; +import { expectedWorkingDays, holidaySet } from "./calendar"; +import { notify } from "./notifications"; +import { earnedBasic } from "./payModels"; +import { piecesByEmployee } from "./pieceWork"; +import { componentsForEmployee, listAssignments } from "./salaryAssignments"; +import type { ComponentScope } from "./salaryAssignments"; +import { getSettings } from "./settings"; + +/** + * Payroll calculation for one Solar Hijri month. + * + * For each active employee: BASIC (from their EmployeeSalary) plus every active + * EARNING component makes up gross; DEDUCTION components plus a loss-of-pay + * charge for unpaid absences make up deductions; net = gross − deductions. + * Day counts come from the attendanceDays projection over the month's Gregorian + * date range. + * + * At small/medium sizes this reads per-employee sequentially. For 100k-employee + * tenants this runs as a Cloud Tasks fan-out over BigQuery-sourced day counts + * (see docs/02); the payslip shape is identical, so clients are unaffected. + */ + +interface SalaryComponentDoc { + /** Needed to match a component against one employee's assignments. */ + id: string; + name: string; + code: string; + type: "EARNING" | "DEDUCTION" | "EMPLOYER_COST"; + calc: "FIXED" | "PERCENT_OF_BASIC" | "PERCENT_OF_GROSS"; + value: number; + /** EARNING only: whether this allowance forms part of the income-tax base. */ + taxable?: boolean; + /** Absent on components written before individual assignment existed. */ + scope?: ComponentScope; + active: boolean; +} + +interface PayslipLine { + componentCode: string; + componentName: string; + type: string; + amount: number; +} + +export interface PayrollRunResult { + runId: string; + periodYear: number; + periodMonth: number; + currency: string; + payslipCount: number; + totalNet: number; + totalGross: number; + totalTax: number; + totalEmployerCost: number; + /** Active employees left out because they have no salary configured. */ + skippedNoSalary: Array<{ employeeId: string; name: string }>; + /** People marked EXITED who nonetheless worked in this period and were paid nothing. */ + skippedExited: Array<{ employeeId: string; name: string }>; + /** + * False when the period had not ended yet at the time of the run, so the + * figures cover only the days elapsed so far and will change if it is run + * again after the month closes. + */ + periodComplete: boolean; +} + +/** + * Unpaid absence is charged as a share of the month's expected working days, + * not as salary/30. + * + * Counting absence in working days while dividing by 30 calendar days is + * inconsistent, and it shows at the extreme: a month with 26 working days, none + * of them attended, docked 26/30 of the salary and left the employee paid for + * four days they did not work. Dividing by the same working days the absence is + * counted in makes a fully absent month cost exactly the month. + */ +function lopPerDay(basic: number, workingDaysInPeriod: number): number { + if (workingDaysInPeriod <= 0) return 0; + return basic / workingDaysInPeriod; +} + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +/** + * Afghanistan monthly wage-withholding income tax (Income Tax Law, Art. 4). + * Brackets on monthly taxable salary (AFN): + * 0–5,000 → 0% + * 5,001–12,500 → 2% of amount over 5,000 + * 12,501–100,000 → 150 + 10% of amount over 12,500 + * over 100,000 → 8,900 + 20% of amount over 100,000 + */ +export function computeAfghanIncomeTax(taxable: number): number { + if (taxable <= 5000) return 0; + if (taxable <= 12500) return round2((taxable - 5000) * 0.02); + if (taxable <= 100000) return round2(150 + (taxable - 12500) * 0.1); + return round2(8900 + (taxable - 100000) * 0.2); +} + +export async function computePayrollRun( + cid: string, + periodYear: number, + periodMonth: number, + startedBy: string, + currency: string, +): Promise { + const fromIso = shamsiMonthStartIso(periodYear, periodMonth); + const toIso = shamsiMonthEndIso(periodYear, periodMonth); + const runId = `${periodYear}_${String(periodMonth).padStart(2, "0")}`; + + const [employeesSnap, formerSnap, componentsSnap, assignments, settings, holidays] = + await Promise.all([ + tenant(cid, "employees").where("status", "==", "ACTIVE").get(), + // Only to warn about, never to pay: see the check after the run. + tenant(cid, "employees").where("status", "==", "EXITED").get(), + tenant(cid, "salaryComponents").where("active", "==", true).get(), + // One query for the whole company rather than one per employee: the + // exceptions are few and the run already reads per employee enough. + listAssignments(cid), + getSettings(cid), + holidaySet(cid, fromIso, toIso), + ]); + + // What each person still owes on money taken before payday, read as this run + // should see it: any repayment a PREVIOUS attempt at this same month made is + // excluded, so recomputing a month takes the same money once rather than + // again on top. + // Piece counts for the period, for anybody paid by the piece. One query + // rather than one per employee — a workshop's month is a few hundred rows. + const piecesFor = await piecesByEmployee(cid, fromIso, toIso); + + const advancesByEmployee = await outstandingFor( + cid, + employeesSnap.docs.map((d) => d.id), + runId, + ); + + // The days people were actually expected in. Weekends and public holidays are + // excluded, so neither is ever mistaken for absence. + const workingDays = expectedWorkingDays( + fromIso, + toIso, + settings.policies.weekendDays, + holidays, + ); + + // …but only the ones that have actually happened can be judged. A run for the + // month still in progress — which is what the portal offers by default — would + // otherwise charge every remaining day as unexcused absence and halve the pay + // of someone with a clean record. A future day is neither worked nor absent. + // + // The divisor below stays the whole month, because a day of salary is worth + // basic ÷ the month's working days no matter when the run happens. + const todayIso = localDateOf(new Date(), settings.profile.timezone); + const elapsedWorkingDays = workingDays.filter((d) => d <= todayIso); + const periodComplete = toIso <= todayIso; + + /** Active employees with no salary on file; they earn nothing and are named. */ + const skipped: Array<{ employeeId: string; name: string }> = []; + + const components = componentsSnap.docs.map( + (d) => ({ id: d.id, ...d.data() }) as SalaryComponentDoc, + ); + + // Which components each person gets, and at what amount, is resolved per + // employee inside the loop below — the same component can apply to one + // person at a different figure, or not at all. + const assignmentsByEmployee = new Map(); + for (const a of assignments) { + const list = assignmentsByEmployee.get(a.employeeId); + if (list) list.push(a); + else assignmentsByEmployee.set(a.employeeId, [a]); + } + + let totalNet = 0; + let totalGross = 0; + let totalTax = 0; + let totalEmployerCost = 0; + // Non-tax deduction components, withheld from employees and owed onward. + let totalWithheld = 0; + let payslipCount = 0; + const now = nowTimestamp(); + + for (const empDoc of employeesSnap.docs) { + const employeeId = empDoc.id; + + const [salarySnap, daysSnap] = await Promise.all([ + tenant(cid, "employeeSalaries").doc(employeeId).get(), + tenant(cid, "attendanceDays") + .where("employeeId", "==", employeeId) + .where("date", ">=", fromIso) + .where("date", "<=", toIso) + .get(), + ]); + if (!salarySnap.exists) { + // Silently omitting people is how a first payroll run comes out looking + // right and paying half the company nothing. Name them on the run so the + // administrator sees who needs a salary before they pay anyone. + const e = empDoc.data() as { firstName?: string; lastName?: string }; + skipped.push({ + employeeId, + name: `${e.firstName ?? ""} ${e.lastName ?? ""}`.trim() || employeeId, + }); + continue; + } + const salaryDoc = salarySnap.data() as + | { basicAmount?: number; payModel?: string } + | undefined; + const rate = salaryDoc?.basicAmount ?? 0; + + // Day counts, driven by the working calendar rather than by whichever + // attendance documents happen to exist. + // + // The previous version iterated the documents it found. A document is only + // written when something happens — a punch, an approved leave, a correction + // — so somebody who simply never came to work produced none at all and was + // paid in full. Walking the expected working days instead makes that case + // visible: a working day with no record is unexcused absence. + const byDate = new Map(); + for (const dayDoc of daysSnap.docs) { + const d = dayDoc.data(); + byDate.set(d.date as string, (d.status as string) ?? ""); + } + + let workedDays = 0; + let paidLeaveDays = 0; + let lopDays = 0; + for (const date of elapsedWorkingDays) { + const status = byDate.get(date); + if (status === "PRESENT") workedDays += 1; + else if (status === "HALF_DAY") { + workedDays += 0.5; + lopDays += 0.5; + } else if (status === "LEAVE") paidLeaveDays += 1; + // PENDING means punches exist but no valid check-in; no record at all + // means the person never turned up. Both are unpaid. + else lopDays += 1; + } + + // What the basic pay comes to under this person's pay model, and whether + // unpaid absence is still to be charged on top of it. For a daily wage or + // piece work it is NOT: those already contain the absence, because a day + // not worked was simply never paid. Deducting as well would take it twice. + const earned = earnedBasic({ + model: salaryDoc?.payModel, + rate, + workedDays, + pieces: piecesFor.get(employeeId) ?? 0, + }); + const basic = earned.amount; + + // This employee's components: the company-wide ones they have not been + // excluded from, plus any assigned only to them, each at whichever amount + // applies to them. Substituting the resolved amount into `value` keeps the + // arithmetic below identical to the company-wide case. + const mine = componentsForEmployee( + components, + assignmentsByEmployee.get(employeeId) ?? [], + ).map((r) => ({ ...r.component, value: r.amount })); + const earnings = mine.filter((c) => c.type === "EARNING"); + const deductions = mine.filter((c) => c.type === "DEDUCTION"); + const employerCosts = mine.filter((c) => c.type === "EMPLOYER_COST"); + + // Earnings: BASIC + each active earning component. + const lines: PayslipLine[] = [ + { componentCode: "BASIC", componentName: "معاش اساسی", type: "EARNING", amount: round2(basic) }, + ]; + // Basic pay is always taxable; an allowance is taxable only if its component + // says so. The base is accumulated here rather than derived from gross, + // which would tax exempt allowances too. + let taxableEarnings = round2(basic); + for (const c of earnings) { + const amount = round2(c.calc === "PERCENT_OF_BASIC" ? (basic * c.value) / 100 : c.value); + lines.push({ componentCode: c.code, componentName: c.name, type: "EARNING", amount }); + if (c.taxable) taxableEarnings = round2(taxableEarnings + amount); + } + const gross = round2(lines.reduce((s, l) => s + l.amount, 0)); + + // Deductions: component deductions + loss-of-pay for unpaid days. + let withheld = 0; + for (const c of deductions) { + let amount = c.value; + if (c.calc === "PERCENT_OF_BASIC") amount = (basic * c.value) / 100; + else if (c.calc === "PERCENT_OF_GROSS") amount = (gross * c.value) / 100; + amount = round2(amount); + lines.push({ componentCode: c.code, componentName: c.name, type: "DEDUCTION", amount }); + withheld = round2(withheld + amount); + } + + // Capped at gross: a spell of unpaid absence cannot dock more than the + // month actually earned. Uncapped, a long spell drove net — and the ledger + // accrual derived from it — negative. + let lopAmount = 0; + if (lopDays > 0 && earned.chargeUnpaidAbsence) { + lopAmount = Math.min(round2(lopPerDay(basic, workingDays.length) * lopDays), gross); + lines.push({ + componentCode: "LOP", + componentName: "کسر غیرحاضری", + type: "DEDUCTION", + amount: lopAmount, + }); + } + + // Statutory income tax (progressive) on pay actually earned: the taxable + // earnings less the unpaid-absence charge. Taxing the pre-LOP figure + // withheld tax on money the employee never received. + const incomeTax = computeAfghanIncomeTax(Math.max(0, round2(taxableEarnings - lopAmount))); + if (incomeTax > 0) { + lines.push({ + componentCode: "TAX", + componentName: "مالیهٔ معاش", + type: "DEDUCTION", + amount: incomeTax, + }); + } + + // Employer-borne cost (e.g. contributions) — not deducted from the employee, + // reported separately for the true cost-to-company. + let employerCost = 0; + for (const c of employerCosts) { + let amount = c.value; + if (c.calc === "PERCENT_OF_BASIC") amount = (basic * c.value) / 100; + else if (c.calc === "PERCENT_OF_GROSS") amount = (gross * c.value) / 100; + employerCost += round2(amount); + lines.push({ componentCode: c.code, componentName: c.name, type: "EMPLOYER_COST", amount: round2(amount) }); + } + employerCost = round2(employerCost); + + // Advances come out LAST, and deliberately so. Tax is owed to the state on + // what was earned; an advance is the company's own money coming back. When + // there is not enough to go round, the thing that yields is the debt to + // the employer, not the debt to the government — and the floor being + // protected is the worker's pay reaching zero rather than going below it. + const advancesOwed = advancesByEmployee.get(employeeId) ?? []; + const payBeforeAdvances = round2( + gross - lines.filter((l) => l.type === "DEDUCTION").reduce((s, l) => s + l.amount, 0), + ); + const repaymentPlan = planRepayments(advancesOwed, payBeforeAdvances); + if (repaymentPlan.total > 0) { + lines.push({ + componentCode: "ADVANCE", + componentName: "کسر پیش‌پرداخت", + type: "DEDUCTION", + amount: repaymentPlan.total, + }); + } + + const totalDeductions = round2( + lines.filter((l) => l.type === "DEDUCTION").reduce((s, l) => s + l.amount, 0), + ); + // Floored at zero — a payslip never pays out a negative amount. With loss + // of pay capped at gross this only trips on a misconfigured deduction + // component, and the ledger below follows the floored figure. + const net = round2(Math.max(0, gross - totalDeductions)); + + const payslipId = `${employeeId}_${runId}`; + await tenant(cid, "payslips").doc(payslipId).set({ + companyId: cid, + runId, + employeeId, + periodYear, + periodMonth, + currency, + gross, + totalDeductions, + net, + incomeTax, + employerCost, + costToCompany: round2(gross + employerCost), + workedDays, + paidLeaveDays, + lopDays, + overtimeMinutes: 0, + status: "FINALIZED", + pdfUrl: null, + lines, + updatedAt: now, + }); + + // After the payslip, not before: if writing the payslip fails, the debt is + // untouched and the month can simply be run again. + if (advancesOwed.length > 0) { + await reconcileRepayments( + cid, + runId, + `${periodYear}-${String(periodMonth).padStart(2, "0")}`, + advancesOwed.map((a) => a.id), + repaymentPlan.repayments, + ); + } + + // "Your payslip is ready" — the one notification an employee actually + // waits for. Sent per payslip because a run can legitimately produce none + // for somebody, and telling them a payslip exists when it does not is + // worse than telling them nothing. + await notify(cid, { + employeeId, + kind: "PAYSLIP_READY", + title: "فیش معاش شما آماده است", + body: `دورهٔ ${periodYear}/${String(periodMonth).padStart(2, "0")}`, + link: "/payslips", + // Keyed to the run, so recomputing a month replaces this rather than + // telling everybody again. + dedupeKey: `payslip_${runId}`, + }); + + totalGross += gross; + totalNet += net; + totalTax += incomeTax; + totalWithheld += withheld; + totalEmployerCost += employerCost; + payslipCount += 1; + } + + // Someone marked EXITED is not paid — but if they worked in this period, the + // run has just left a person who turned up with nothing at all, and says so + // nowhere. This does not pay them; it makes them impossible to miss. The way + // out is to set them ACTIVE, run the month again, then mark them EXITED. + const workedThenLeft: Array<{ employeeId: string; name: string }> = []; + for (const doc of formerSnap.docs) { + const worked = await tenant(cid, "attendanceDays") + .where("employeeId", "==", doc.id) + .where("date", ">=", fromIso) + .where("date", "<=", toIso) + .limit(1) + .get(); + if (worked.empty) continue; + const e = doc.data() as { firstName?: string; lastName?: string }; + workedThenLeft.push({ + employeeId: doc.id, + name: `${e.firstName ?? ""} ${e.lastName ?? ""}`.trim() || doc.id, + }); + } + + await tenant(cid, "payrollRuns").doc(runId).set({ + companyId: cid, + periodYear, + periodMonth, + status: "APPROVED", + // A run for a month still in progress is a preview, not the final word. + periodComplete, + skippedNoSalary: skipped, + skippedExited: workedThenLeft, + startedBy, + approvedBy: startedBy, + currency, + payslipCount, + totalGross: round2(totalGross), + totalNet: round2(totalNet), + totalTax: round2(totalTax), + totalEmployerCost: round2(totalEmployerCost), + lockedAt: now, + createdAt: now, + updatedAt: now, + }); + + // Accrue the run to the general ledger. The expense recognised is what the + // run actually produced: take-home pay, plus everything withheld on the + // employees' behalf, plus employer-borne cost. Deriving the debit from the + // credits is what keeps the entry balanced whatever components a company has + // configured — the previous version credited Salaries Payable with + // gross − tax, overstating the liability by the loss-of-pay charge and by + // every non-tax deduction, and debited an equally overstated expense. + if (payslipCount > 0) { + const credits = [ + { accountCode: "2100", accountName: "Salaries Payable", debit: 0, credit: round2(totalNet) }, + { accountCode: "2200", accountName: "Taxes Payable", debit: 0, credit: round2(totalTax) }, + { + accountCode: "2300", + accountName: "Employee Withholdings", + debit: 0, + credit: round2(totalWithheld), + }, + { + accountCode: "2400", + accountName: "Employer Contributions Payable", + debit: 0, + credit: round2(totalEmployerCost), + }, + ].filter((l) => l.credit > 0); + const expense = round2(credits.reduce((sum, l) => sum + l.credit, 0)); + + if (expense > 0) { + // A company whose chart was seeded before these codes existed would + // otherwise post to accounts the trial balance cannot resolve. + await ensureAccounts(cid, ["5000", ...credits.map((l) => l.accountCode)]); + + // Idempotent: the id is derived from the run, so re-running the month + // overwrites this entry in place instead of accruing it twice. Entries an + // earlier version wrote under a random id are cleared first. + const entryId = `PAYROLL_${runId}`; + const prior = await tenant(cid, "journalEntries") + .where("reference", "==", runId) + .where("source", "==", "PAYROLL") + .get(); + await Promise.all( + prior.docs.filter((d) => d.id !== entryId).map((d) => d.ref.delete()), + ); + + await postJournalEntry(cid, { + // A completed month accrues on its last day, which is what the books + // expect. A run of a month still in progress must not: dating it at the + // period end puts the whole salary cost on a date that has not arrived, + // so the ledger and every trend built on it show an expense in the + // future. Such a run is recognised on the day it was made. + date: periodComplete ? toIso : todayIso, + memo: `Payroll ${periodYear}/${String(periodMonth).padStart(2, "0")}`, + reference: runId, + source: "PAYROLL", + entryId, + createdBy: startedBy, + lines: [ + { accountCode: "5000", accountName: "Salaries & Wages", debit: expense, credit: 0 }, + ...credits, + ], + }); + } + } + + return { + runId, + periodYear, + periodMonth, + currency, + payslipCount, + totalNet: round2(totalNet), + totalGross: round2(totalGross), + totalTax: round2(totalTax), + totalEmployerCost: round2(totalEmployerCost), + periodComplete, + skippedNoSalary: skipped, + skippedExited: workedThenLeft, + }; +} diff --git a/backend/functions/src/services/pieceWork.ts b/backend/functions/src/services/pieceWork.ts new file mode 100644 index 0000000..6174bcd --- /dev/null +++ b/backend/functions/src/services/pieceWork.ts @@ -0,0 +1,88 @@ +import { ApiError } from "../lib/errors"; +import { nowTimestamp, tenant } from "../lib/firestore"; +import { ulid } from "../lib/ids"; + +/** + * What a piece-rate worker finished. + * + * A tailoring workshop pays per garment, so payroll needs a count for the + * period. It is kept as one document per entry — "40 on the 3rd", "35 on the + * 4th" — rather than as a running monthly total, because a total nobody can + * break down is a total nobody can dispute, and disputes about piece counts + * are exactly what a workshop's book exists to settle. + */ + +export interface PieceRecordDoc { + employeeId: string; + employeeName: string; + /** ISO date the work was completed. */ + date: string; + quantity: number; + note: string | null; + recordedBy: string; + createdAt: FirebaseFirestore.Timestamp; +} + +export function pieceRecordToDto(id: string, doc: PieceRecordDoc): Record { + return { + id, + employeeId: doc.employeeId, + employeeName: doc.employeeName, + date: doc.date, + quantity: doc.quantity, + note: doc.note, + }; +} + +export async function recordPieces( + cid: string, + input: { employeeId: string; date: string; quantity: number; note: string | null }, + recordedBy: string, +): Promise<{ id: string; doc: PieceRecordDoc }> { + const employee = await tenant(cid, "employees").doc(input.employeeId).get(); + if (!employee.exists) throw ApiError.notFound("Employee not found"); + const e = employee.data() as { firstName?: string; lastName?: string }; + + const doc: PieceRecordDoc = { + employeeId: input.employeeId, + employeeName: `${e.firstName ?? ""} ${e.lastName ?? ""}`.trim(), + date: input.date, + quantity: input.quantity, + note: input.note, + recordedBy, + createdAt: nowTimestamp(), + }; + const id = ulid(); + await tenant(cid, "pieceRecords").doc(id).create(doc); + return { id, doc }; +} + +export async function deletePieceRecord(cid: string, id: string): Promise { + const ref = tenant(cid, "pieceRecords").doc(id); + if (!(await ref.get()).exists) throw ApiError.notFound("Record not found"); + await ref.delete(); +} + +/** + * How many pieces each person finished between two dates, inclusive. + * + * One query for the whole company: a payroll run already reads enough per + * employee, and a workshop's month is a few hundred rows at most. + */ +export async function piecesByEmployee( + cid: string, + fromIso: string, + toIso: string, +): Promise> { + const snap = await tenant(cid, "pieceRecords") + .where("date", ">=", fromIso) + .where("date", "<=", toIso) + .get(); + + const totals = new Map(); + for (const doc of snap.docs) { + const d = doc.data() as PieceRecordDoc; + totals.set(d.employeeId, (totals.get(d.employeeId) ?? 0) + (d.quantity ?? 0)); + } + return totals; +} diff --git a/backend/functions/src/services/punch.ts b/backend/functions/src/services/punch.ts new file mode 100644 index 0000000..b6ba150 --- /dev/null +++ b/backend/functions/src/services/punch.ts @@ -0,0 +1,220 @@ +import { Timestamp } from "firebase-admin/firestore"; +import { z } from "zod"; +import { ApiError, ErrorCodes } from "../lib/errors"; +import { isValidUlid } from "../lib/ids"; +import { audit, nowTimestamp, tenant } from "../lib/firestore"; +import { verifyFaceToken } from "../lib/face-token"; +import { haversineMeters, checkGeofence } from "./geo"; +import { verifyKioskToken } from "./kiosk"; +import { getSettings } from "./settings"; +import { localDateOf, punchToDto, recomputeAttendanceDay, type PunchDoc } from "./attendance"; + +export const punchCreateSchema = z.object({ + id: z.string().length(26), + punchedAt: z.string().datetime(), + type: z.enum(["IN", "OUT"]), + method: z.enum(["GPS", "QR", "FACE", "MANUAL", "KIOSK"]), + latitude: z.number().min(-90).max(90).nullish(), + longitude: z.number().min(-180).max(180).nullish(), + accuracyMeters: z.number().min(0).nullish(), + geofenceId: z.string().nullish(), + insideFence: z.boolean().optional().default(false), + kioskToken: z.string().nullish(), + note: z.string().max(500).nullish(), + // Optional check-in selfie (small base64 JPEG data URL) for photo-verified + // attendance. Capped well under the Firestore 1MB doc limit. + selfie: z.string().max(200_000).nullish(), + /** + * Signed proof of a successful /attendance/face/verify. `faceVerified` is + * derived from this server-side and is never accepted from the client. + */ + faceToken: z.string().max(200).nullish(), +}); + +export type PunchCreate = z.infer; + +const MAX_FUTURE_SKEW_MS = 10 * 60 * 1000; +const MAX_BACKDATE_MS = 7 * 24 * 60 * 60 * 1000; // multi-day offline window +const IMPLAUSIBLE_SPEED_KMH = 250; + +/** + * Applies one punch: append-only, idempotent on the client-generated ULID. + * The punch is always recorded as evidence; failed validations mark it + * serverValidated=false with a reason instead of dropping the event. + */ +export async function applyPunch( + cid: string, + employeeId: string, + payload: PunchCreate, + hmacSecret: string, +): Promise> { + if (!isValidUlid(payload.id)) { + throw ApiError.validation("Punch id must be a ULID", { id: "Invalid ULID" }); + } + + const ref = tenant(cid, "punches").doc(payload.id); + const existing = await ref.get(); + if (existing.exists) { + // Idempotent replay: the first write wins. Recompute the day before + // returning, because a replay almost always means the first attempt stored + // the punch and then failed while projecting it. Without this the + // projection is lost for good and the punch never reaches the attendance + // board, even though the client sees a success and stops retrying. + const stored = existing.data() as PunchDoc; + const { profile } = await getSettings(cid); + await recomputeAttendanceDay( + cid, + employeeId, + localDateOf(stored.punchedAt.toDate(), profile.timezone), + profile.timezone, + ); + return punchToDto(payload.id, stored); + } + + const punchedAt = new Date(payload.punchedAt); + const now = Date.now(); + + let serverValidated = true; + let invalidReason: string | null = null; + let geofenceId: string | null = payload.geofenceId ?? null; + let insideFence = false; + let kioskId: string | null = null; + + if (punchedAt.getTime() > now + MAX_FUTURE_SKEW_MS) { + serverValidated = false; + invalidReason = "TIME_SKEW"; + } else if (punchedAt.getTime() < now - MAX_BACKDATE_MS) { + serverValidated = false; + invalidReason = "TOO_OLD"; + } + + // FACE punches are GPS punches with an identity check bolted on, so they go + // through exactly the same geofence validation — otherwise switching method + // would be a way to opt out of location rules. + const isLocatedMethod = payload.method === "GPS" || payload.method === "FACE"; + if (serverValidated && isLocatedMethod) { + if (payload.latitude == null || payload.longitude == null) { + throw ApiError.validation(`${payload.method} punches require coordinates`, { + latitude: `Required for ${payload.method} method`, + }); + } + const check = await checkGeofence( + cid, + payload.latitude, + payload.longitude, + payload.accuracyMeters ?? 0, + ); + geofenceId = check.geofenceId; + insideFence = check.insideFence; + if (check.fencesConfigured && !check.insideFence) { + serverValidated = false; + invalidReason = ErrorCodes.GEOFENCE_VIOLATION; + } + } + + if (serverValidated && payload.method === "QR") { + const token = payload.kioskToken ? verifyKioskToken(hmacSecret, payload.kioskToken) : null; + if (!token) { + serverValidated = false; + invalidReason = ErrorCodes.KIOSK_TOKEN_INVALID; + } else { + kioskId = token.kioskId; + insideFence = true; // physically at the kiosk + } + } + + // Speed-of-travel plausibility vs the most recent located, validated punch. + if (serverValidated && payload.latitude != null && payload.longitude != null) { + // Must be the newest punch BEFORE this one. Taking the newest punch overall + // compares an offline punch being synced late against a punch that happened + // after it; the negative interval was floored at one second, turning any + // backdated punch into an implausible-travel rejection. + const prevSnap = await tenant(cid, "punches") + .where("employeeId", "==", employeeId) + .where("punchedAt", "<", Timestamp.fromDate(punchedAt)) + .orderBy("punchedAt", "desc") + .limit(1) + .get(); + if (!prevSnap.empty) { + const prev = prevSnap.docs[0].data() as PunchDoc; + if (prev.serverValidated && prev.latitude != null && prev.longitude != null) { + const meters = haversineMeters( + prev.latitude, + prev.longitude, + payload.latitude, + payload.longitude, + ); + const hours = Math.max( + (punchedAt.getTime() - prev.punchedAt.toMillis()) / 3_600_000, + 1 / 3600, // floor at one second to avoid divide-by-zero + ); + if (meters / 1000 / hours > IMPLAUSIBLE_SPEED_KMH) { + serverValidated = false; + invalidReason = "IMPLAUSIBLE_TRAVEL"; + } + } + } + } + + // Face verification is only ever believed when the punch carries a token this + // server signed after a real match. A missing or stale token does NOT void the + // punch — attendance must never hinge on a finicky face match — it simply + // means the punch is not verified, and is flagged below if the company + // expects verification. + const faceVerified = + payload.faceToken != null && verifyFaceToken(hmacSecret, employeeId, payload.faceToken); + + const settings = await getSettings(cid); + const timezone = settings.profile.timezone; + + // A company that turned face recognition on expects self-service check-ins to + // be identity-checked. Unverified ones still count, but a manager is told. + // QR/kiosk punches carry their own presence proof (a rotating token scanned at + // the device) and MANUAL ones are admin-entered, so neither is flagged here. + const needsReview = + settings.features.faceRecognition && isLocatedMethod && !faceVerified; + + const doc: PunchDoc = { + companyId: cid, + employeeId, + punchedAt: Timestamp.fromDate(punchedAt), + type: payload.type, + method: payload.method, + latitude: payload.latitude ?? null, + longitude: payload.longitude ?? null, + accuracyMeters: payload.accuracyMeters ?? null, + geofenceId, + insideFence, + kioskId, + note: payload.note ?? null, + selfie: payload.selfie ?? null, + faceVerified, + needsReview, + reviewReason: needsReview ? "FACE_NOT_VERIFIED" : null, + serverValidated, + invalidReason, + updatedAt: nowTimestamp(), + }; + // create() (not set) preserves append-only semantics under write races. + await ref.create(doc); + + await recomputeAttendanceDay(cid, employeeId, localDateOf(punchedAt, timezone), timezone); + + await audit(cid, { + actorId: employeeId, + actorRole: "EMPLOYEE", + action: "attendance.punch", + resourceType: "punches", + resourceId: payload.id, + after: { + type: payload.type, + method: payload.method, + faceVerified, + needsReview, + serverValidated, + invalidReason, + }, + }); + + return punchToDto(payload.id, doc); +} diff --git a/backend/functions/src/services/regularization.ts b/backend/functions/src/services/regularization.ts new file mode 100644 index 0000000..e259c51 --- /dev/null +++ b/backend/functions/src/services/regularization.ts @@ -0,0 +1,236 @@ +import { Timestamp } from "firebase-admin/firestore"; +import { canDecideAnyRequest } from "../middleware/rbac"; +import { z } from "zod"; +import { ApiError, ErrorCodes } from "../lib/errors"; +import { isValidUlid } from "../lib/ids"; +import { audit, nowTimestamp, tenant, toIso } from "../lib/firestore"; +import { notify } from "./notifications"; + +export const regularizationCreateSchema = z.object({ + id: z.string().length(26), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + requestedInAt: z.string().datetime().nullish(), + requestedOutAt: z.string().datetime().nullish(), + reason: z.string().min(1).max(1000), +}); + +export type RegularizationCreate = z.infer; + +export const regularizationDecisionSchema = z.object({ + decision: z.enum(["APPROVE", "REJECT"]), + note: z.string().max(1000).nullish(), +}); + +interface RegularizationDoc { + companyId: string; + employeeId: string; + employeeName: string | null; + date: string; + requestedInAt: Timestamp | null; + requestedOutAt: Timestamp | null; + reason: string; + status: "PENDING" | "APPROVED" | "REJECTED" | "CANCELLED"; + currentApproverId: string | null; + decidedAt: Timestamp | null; + decidedBy: string | null; + decisionNote: string | null; + createdAt: Timestamp; + updatedAt: Timestamp; +} + +export function regularizationToDto(id: string, d: RegularizationDoc): Record { + return { + id, + companyId: d.companyId, + employeeId: d.employeeId, + employeeName: d.employeeName, + date: d.date, + requestedInAt: toIso(d.requestedInAt), + requestedOutAt: toIso(d.requestedOutAt), + reason: d.reason, + status: d.status, + currentApproverId: d.currentApproverId, + decidedAt: toIso(d.decidedAt), + decisionNote: d.decisionNote, + createdAt: toIso(d.createdAt), + updatedAt: toIso(d.updatedAt), + }; +} + +/** Employee-filed request to correct a day's attendance. Idempotent on the ULID. */ +/** + * Corrections a person may act on. Mirrors listLeaveRequests, and for the same + * reason: decideRegularization lets an administrator decide any correction, so + * the queue must show an administrator every pending correction. Otherwise the + * card never appears and the uncorrected days are paid as absence. + */ +export async function listRegularizations( + cid: string, + employeeId: string, + roles: string[], + scope: string, +): Promise>> { + const col = tenant(cid, "regularizations"); + const query = + scope !== "approvals" + ? col.where("employeeId", "==", employeeId).limit(200) + : canDecideAnyRequest(roles) + ? col.where("status", "==", "PENDING").limit(200) + : col.where("currentApproverId", "==", employeeId).limit(200); + + const snapshot = await query.get(); + return snapshot.docs.map((doc) => + regularizationToDto(doc.id, doc.data() as RegularizationDoc), + ); +} + +export async function createRegularization( + cid: string, + employeeId: string, + payload: RegularizationCreate, +): Promise> { + if (!isValidUlid(payload.id)) { + throw ApiError.validation("Request id must be a ULID", { id: "Invalid ULID" }); + } + if (!payload.requestedInAt && !payload.requestedOutAt) { + throw ApiError.validation("Provide a corrected check-in or check-out time", { + requestedInAt: "Required", + }); + } + + const ref = tenant(cid, "regularizations").doc(payload.id); + const existing = await ref.get(); + if (existing.exists) { + return regularizationToDto(payload.id, existing.data() as RegularizationDoc); + } + + const empSnap = await tenant(cid, "employees").doc(employeeId).get(); + const emp = empSnap.data() as + | { firstName?: string; lastName?: string; managerId?: string | null } + | undefined; + + const now = nowTimestamp(); + const doc: RegularizationDoc = { + companyId: cid, + employeeId, + employeeName: emp ? `${emp.firstName ?? ""} ${emp.lastName ?? ""}`.trim() : null, + date: payload.date, + requestedInAt: payload.requestedInAt ? Timestamp.fromDate(new Date(payload.requestedInAt)) : null, + requestedOutAt: payload.requestedOutAt ? Timestamp.fromDate(new Date(payload.requestedOutAt)) : null, + reason: payload.reason, + status: "PENDING", + currentApproverId: emp?.managerId ?? null, + decidedAt: null, + decidedBy: null, + decisionNote: null, + createdAt: now, + updatedAt: now, + }; + await ref.create(doc); + return regularizationToDto(payload.id, doc); +} + +/** Approve/reject. On approval the corrected times are written to the day. */ +export async function decideRegularization( + cid: string, + id: string, + decidedBy: string, + roles: string[], + decision: "APPROVE" | "REJECT", + note: string | null, +): Promise> { + const ref = tenant(cid, "regularizations").doc(id); + const snap = await ref.get(); + if (!snap.exists) { + throw ApiError.notFound("Regularization not found"); + } + const reg = snap.data() as RegularizationDoc; + if (reg.status !== "PENDING") { + throw ApiError.business(ErrorCodes.INVALID_STATE, `Already ${reg.status}`); + } + const isAssigned = reg.currentApproverId === decidedBy; + const isAdmin = roles.includes("HR_ADMIN") || roles.includes("COMPANY_ADMIN"); + if (!isAssigned && !isAdmin) { + throw ApiError.permissionDenied("You are not the approver for this request"); + } + if (reg.employeeId === decidedBy) { + throw ApiError.permissionDenied("You cannot decide your own request"); + } + + if (decision === "APPROVE") { + await applyToAttendanceDay(cid, reg); + } + + const now = nowTimestamp(); + await ref.update({ + status: decision === "APPROVE" ? "APPROVED" : "REJECTED", + decidedAt: now, + decidedBy, + decisionNote: note, + currentApproverId: null, + updatedAt: now, + }); + + await notify(cid, { + employeeId: reg.employeeId, + kind: "CORRECTION_DECIDED", + title: decision === "APPROVE" ? "اصلاح حاضری تأیید شد" : "اصلاح حاضری رد شد", + body: note?.trim() ? note : `برای ${reg.date}`, + link: "/attendance", + }); + + await audit(cid, { + actorId: decidedBy, + actorRole: roles.join(","), + action: `attendance.regularization.${decision.toLowerCase()}`, + resourceType: "regularizations", + resourceId: id, + after: { decision, date: reg.date }, + }); + + return regularizationToDto(id, { ...reg, status: decision === "APPROVE" ? "APPROVED" : "REJECTED" }); +} + +/** + * Writes the approved correction onto the AttendanceDay projection. A manager + * approved these times, so no lateness penalty is applied; worked minutes come + * straight from the corrected in/out. (Shift-aware late recompute for corrected + * days is a future refinement.) + */ +async function applyToAttendanceDay(cid: string, reg: RegularizationDoc): Promise { + const dayId = `${reg.employeeId}_${reg.date}`; + const inAt = reg.requestedInAt; + const outAt = reg.requestedOutAt; + + let workedMinutes = 0; + if (inAt && outAt) { + workedMinutes = Math.max(0, Math.floor((outAt.toMillis() - inAt.toMillis()) / 60_000)); + } + const status = workedMinutes >= 240 ? "PRESENT" : workedMinutes > 0 ? "HALF_DAY" : "PENDING"; + + const now = nowTimestamp(); + const ref = tenant(cid, "attendanceDays").doc(dayId); + + // A correction amends the day; it does not replace it. A full set() built + // only from the requested times wiped the check-in photo, the face-verified + // flag, the review flags and the record of refused punches — and a request + // that supplied only one of the two times zeroed the other and the worked + // total with it. Only the fields the correction actually decides are written. + const patch: Record = { + regularized: true, + computedAt: now, + updatedAt: now, + }; + if (inAt) patch.firstInAt = inAt; + if (outAt) patch.lastOutAt = outAt; + // Worked time and status are only recomputed when both ends are known; + // a one-sided correction leaves the existing figures alone. + if (inAt && outAt) { + patch.workedMinutes = workedMinutes; + patch.status = status; + patch.lateMinutes = 0; + patch.earlyOutMinutes = 0; + patch.overtimeMinutes = 0; + } + await ref.set(patch, { merge: true }); +} diff --git a/backend/functions/src/services/salaryAssignments.test.ts b/backend/functions/src/services/salaryAssignments.test.ts new file mode 100644 index 0000000..768f0ce --- /dev/null +++ b/backend/functions/src/services/salaryAssignments.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { componentsForEmployee, assignmentId } from "./salaryAssignments"; +import type { AssignmentDoc } from "./salaryAssignments"; + +/** + * The rule that decides whose payslip a component reaches, and at what figure. + * Four cases from two fields; each one is somebody's actual pay. + */ + +interface C { + id: string; + value: number; + scope?: "ALL" | "INDIVIDUAL"; +} + +const transport: C = { id: "transport", value: 2000, scope: "ALL" }; +const bonus: C = { id: "bonus", value: 5000, scope: "INDIVIDUAL" }; +/** Written before scope existed; it applied to everyone and must keep doing so. */ +const legacy: C = { id: "legacy", value: 300 }; + +function assign(over: Partial & { componentId: string }): AssignmentDoc { + return { employeeId: "e1", value: null, active: true, ...over }; +} + +function resolve(components: C[], assignments: AssignmentDoc[]) { + return componentsForEmployee(components, assignments).map((r) => [r.component.id, r.amount]); +} + +describe("which components apply to an employee", () => { + it("gives a company-wide component to someone with no assignment", () => { + expect(resolve([transport], [])).toEqual([["transport", 2000]]); + }); + + it("does not give an individual component to someone with no assignment", () => { + expect(resolve([bonus], [])).toEqual([]); + }); + + it("gives an individual component to the person it is assigned to", () => { + expect(resolve([bonus], [assign({ componentId: "bonus" })])).toEqual([["bonus", 5000]]); + }); + + it("uses the assigned amount in place of the component's", () => { + expect(resolve([transport], [assign({ componentId: "transport", value: 3500 })])).toEqual([ + ["transport", 3500], + ]); + }); + + it("treats an assigned amount of zero as zero, not as absent", () => { + // `?? c.value` on a 0 would have silently paid the full 2000. + expect(resolve([transport], [assign({ componentId: "transport", value: 0 })])).toEqual([ + ["transport", 0], + ]); + }); + + it("withholds a company-wide component from one person", () => { + expect(resolve([transport], [assign({ componentId: "transport", active: false })])).toEqual( + [], + ); + }); + + it("keeps a component that predates scope applying to everyone", () => { + expect(resolve([legacy], [])).toEqual([["legacy", 300]]); + }); + + it("still lets a legacy component be overridden or withheld", () => { + expect(resolve([legacy], [assign({ componentId: "legacy", value: 100 })])).toEqual([ + ["legacy", 100], + ]); + expect(resolve([legacy], [assign({ componentId: "legacy", active: false })])).toEqual([]); + }); + + it("ignores an assignment naming a component the company no longer has", () => { + expect(resolve([transport], [assign({ componentId: "deleted" })])).toEqual([ + ["transport", 2000], + ]); + }); + + it("resolves a mixed set in one pass", () => { + expect( + resolve( + [transport, bonus, legacy], + [assign({ componentId: "bonus" }), assign({ componentId: "transport", active: false })], + ), + ).toEqual([ + ["bonus", 5000], + ["legacy", 300], + ]); + }); + + it("derives the document id from the pair, so assigning twice corrects", () => { + expect(assignmentId("e1", "transport")).toBe("e1__transport"); + expect(assignmentId("e1", "transport")).toBe(assignmentId("e1", "transport")); + }); +}); diff --git a/backend/functions/src/services/salaryAssignments.ts b/backend/functions/src/services/salaryAssignments.ts new file mode 100644 index 0000000..1d8e280 --- /dev/null +++ b/backend/functions/src/services/salaryAssignments.ts @@ -0,0 +1,145 @@ +import { z } from "zod"; +import { nowTimestamp, tenant } from "../lib/firestore"; + +/** + * Which salary components apply to which employee, and at what amount. + * + * A component is a definition — "transport allowance, 2000". Whether a given + * person gets it, and whether they get that figure, is this. Four cases, all + * from the same two fields: + * + * company-wide scope ALL, no assignment everyone gets 2000 + * different for one scope ALL, assignment value 3000 that person gets 3000 + * withheld from one scope ALL, assignment inactive that person gets none + * only for some scope INDIVIDUAL + assignment only the assigned + * + * Components written before `scope` existed have none, and are read as ALL: + * they applied to everybody, and that must not change under them. + */ + +/** Whether a component applies to everyone by default or only where assigned. */ +export type ComponentScope = "ALL" | "INDIVIDUAL"; + +export interface AssignmentDoc { + employeeId: string; + componentId: string; + /** Overrides the component's own amount. Null means "use the component's". */ + value: number | null; + /** False withholds an otherwise company-wide component from this employee. */ + active: boolean; +} + +export const assignmentWriteSchema = z.object({ + // Null and "no value" both mean the component's own figure; accepting either + // spares every caller from having to know which one this API prefers. + value: z.number().min(0).max(100_000_000).nullish(), + active: z.boolean().optional().default(true), +}); + +export type AssignmentWrite = z.infer; + +/** + * The document id. Deriving it from the pair rather than minting one makes + * assigning the same component twice a correction instead of a duplicate — the + * same reason payslip ids are derived from the employee and the run. + */ +export function assignmentId(employeeId: string, componentId: string): string { + return `${employeeId}__${componentId}`; +} + +export async function listAssignments(cid: string): Promise { + const snap = await tenant(cid, "employeeComponents").limit(5000).get(); + return snap.docs.map((d) => { + const v = d.data(); + return { + employeeId: v.employeeId as string, + componentId: v.componentId as string, + value: (v.value as number | null | undefined) ?? null, + active: (v.active as boolean | undefined) ?? true, + }; + }); +} + +export async function listAssignmentsFor( + cid: string, + employeeId: string, +): Promise { + const snap = await tenant(cid, "employeeComponents") + .where("employeeId", "==", employeeId) + .limit(500) + .get(); + return snap.docs.map((d) => { + const v = d.data(); + return { + employeeId: v.employeeId as string, + componentId: v.componentId as string, + value: (v.value as number | null | undefined) ?? null, + active: (v.active as boolean | undefined) ?? true, + }; + }); +} + +export async function setAssignment( + cid: string, + employeeId: string, + componentId: string, + input: AssignmentWrite, +): Promise { + const doc: AssignmentDoc = { + employeeId, + componentId, + value: input.value ?? null, + active: input.active ?? true, + }; + await tenant(cid, "employeeComponents") + .doc(assignmentId(employeeId, componentId)) + .set({ companyId: cid, ...doc, updatedAt: nowTimestamp() }); + return doc; +} + +/** + * Removes the assignment, which returns the employee to the component's own + * behaviour: a company-wide component applies again at its own amount, and an + * individual one stops applying. + */ +export async function clearAssignment( + cid: string, + employeeId: string, + componentId: string, +): Promise { + await tenant(cid, "employeeComponents") + .doc(assignmentId(employeeId, componentId)) + .delete(); +} + +/** A component as payroll needs to see it: does it apply, and at what amount. */ +export interface ResolvedComponent { + component: T; + amount: number; +} + +/** + * Resolves one employee's components from the company's definitions and their + * own assignments. + * + * Pure, so payroll's arithmetic can be tested without a database, and so the + * rule lives in exactly one place rather than being re-derived per call site. + */ +export function componentsForEmployee< + T extends { id: string; value: number; scope?: ComponentScope }, +>(components: T[], assignments: AssignmentDoc[]): ResolvedComponent[] { + const byComponent = new Map(assignments.map((a) => [a.componentId, a])); + const out: ResolvedComponent[] = []; + + for (const c of components) { + const a = byComponent.get(c.id); + // No scope at all means the component predates individual assignment and + // has always applied to everyone. + const appliesByDefault = (c.scope ?? "ALL") === "ALL"; + const applies = a ? a.active : appliesByDefault; + if (!applies) continue; + out.push({ component: c, amount: a?.value ?? c.value }); + } + + return out; +} diff --git a/backend/functions/src/services/settings.ts b/backend/functions/src/services/settings.ts new file mode 100644 index 0000000..e05f044 --- /dev/null +++ b/backend/functions/src/services/settings.ts @@ -0,0 +1,195 @@ +import { z } from "zod"; +import { audit, db, nowTimestamp } from "../lib/firestore"; + +/** + * Per-company configuration: which modules are turned on ("امکانات قابل ویرایش") + * and the work policies that drive attendance/payroll. Stored on the company + * document under `settings`; missing keys fall back to DEFAULT_SETTINGS so the + * shape can grow without a migration. + */ +export interface CompanyFeatures { + shifts: boolean; + leave: boolean; + payroll: boolean; + regularization: boolean; + announcements: boolean; + geofencing: boolean; + qrKiosk: boolean; + faceRecognition: boolean; + /** Advanced finance & accounting module (expenses, ledger, reports). */ + finance: boolean; +} + +export interface CompanyPolicies { + standardDailyMinutes: number; + /** ISO weekday numbers (Mon=1 … Sun=7). Afghanistan defaults to Friday (5). */ + weekendDays: number[]; + lateGraceMinutes: number; + overtimeEnabled: boolean; +} + +export interface CompanyProfile { + currency: string; + timezone: string; + /** + * What kind of work this company does — see services/businessTypes.ts. + * + * It lives here so a company can correct it themselves: businesses change, + * and the answer given in a hurry on signup day is often not the right one. + * Nothing branches on it. It shaped the defaults once, at signup, and every + * one of those settings is editable on this same page afterwards. + */ + businessType?: string | null; +} + +export interface CompanySettings { + features: CompanyFeatures; + policies: CompanyPolicies; + profile: CompanyProfile; +} + +export const DEFAULT_SETTINGS: CompanySettings = { + features: { + shifts: true, + leave: true, + payroll: true, + regularization: true, + announcements: true, + geofencing: true, + qrKiosk: true, + faceRecognition: false, + finance: true, + }, + policies: { + standardDailyMinutes: 480, + weekendDays: [5], + lateGraceMinutes: 10, + overtimeEnabled: true, + }, + profile: { + currency: "AFN", + timezone: "Asia/Kabul", + businessType: null, + }, +}; + +/** PATCH body: every field optional so the client can send just what changed. */ +function isUsableTimezone(tz: string): boolean { + try { + new Intl.DateTimeFormat("en-CA", { timeZone: tz }); + return true; + } catch { + return false; + } +} + +export const settingsUpdateSchema = z.object({ + features: z + .object({ + shifts: z.boolean(), + leave: z.boolean(), + payroll: z.boolean(), + regularization: z.boolean(), + announcements: z.boolean(), + geofencing: z.boolean(), + qrKiosk: z.boolean(), + faceRecognition: z.boolean(), + finance: z.boolean(), + }) + .partial() + .optional(), + policies: z + .object({ + standardDailyMinutes: z.number().int().min(60).max(1440), + weekendDays: z.array(z.number().int().min(1).max(7)).max(7), + lateGraceMinutes: z.number().int().min(0).max(120), + overtimeEnabled: z.boolean(), + }) + .partial() + .optional(), + profile: z + .object({ + currency: z.string().length(3), + // Every enforced request formats a date in this zone. An unusable value + // makes Intl throw, which would 500 the whole tenant — including the + // licence check — until somebody fixed the document by hand. + timezone: z + .string() + .min(1) + .max(64) + .refine(isUsableTimezone, "Not a timezone this server recognises"), + // Not an enum on purpose: the catalogue will grow, and a company holding + // a type we later retire should keep working rather than be unable to + // save its own settings. Unknown values simply stop meaning anything. + businessType: z.string().max(40).nullable(), + }) + .partial() + .optional(), +}); + +export type SettingsUpdate = z.infer; + +/** Merges stored settings over the defaults so new keys always resolve. */ +export function mergeSettings(stored: Partial | undefined): CompanySettings { + return { + features: { ...DEFAULT_SETTINGS.features, ...(stored?.features ?? {}) }, + policies: { ...DEFAULT_SETTINGS.policies, ...(stored?.policies ?? {}) }, + profile: { ...DEFAULT_SETTINGS.profile, ...(stored?.profile ?? {}) }, + }; +} + +export async function getSettings(cid: string): Promise { + const snap = await db.collection("companies").doc(cid).get(); + const data = snap.data() as + | { settings?: Partial; currency?: string; timezone?: string } + | undefined; + const merged = mergeSettings(data?.settings); + // Fall back to the company doc's own currency/timezone if profile is unset. + if (!data?.settings?.profile) { + merged.profile = { + currency: data?.currency ?? merged.profile.currency, + timezone: data?.timezone ?? merged.profile.timezone, + }; + } + return merged; +} + +export async function updateSettings( + cid: string, + patch: SettingsUpdate, + actorId: string, + roles: string[], +): Promise { + const current = await getSettings(cid); + const next: CompanySettings = { + features: { ...current.features, ...(patch.features ?? {}) }, + policies: { ...current.policies, ...(patch.policies ?? {}) }, + profile: { ...current.profile, ...(patch.profile ?? {}) }, + }; + + await db + .collection("companies") + .doc(cid) + .set( + { + settings: next, + // Keep the top-level mirror in sync for older readers. + currency: next.profile.currency, + timezone: next.profile.timezone, + updatedAt: nowTimestamp(), + }, + { merge: true }, + ); + + await audit(cid, { + actorId, + actorRole: roles.join(","), + action: "settings.update", + resourceType: "settings", + resourceId: cid, + before: current, + after: next, + }); + + return next; +} diff --git a/backend/functions/src/services/shifts.ts b/backend/functions/src/services/shifts.ts new file mode 100644 index 0000000..c4f62cf --- /dev/null +++ b/backend/functions/src/services/shifts.ts @@ -0,0 +1,258 @@ +import { Timestamp } from "firebase-admin/firestore"; +import { z } from "zod"; +import { ApiError } from "../lib/errors"; +import { audit, db, nowTimestamp, tenant, toIso } from "../lib/firestore"; +import { ulid } from "../lib/ids"; + +const TIME = /^([01]\d|2[0-3]):[0-5]\d$/; + +export const shiftWriteSchema = z.object({ + name: z.string().min(1).max(80), + code: z.string().min(1).max(20), + startTime: z.string().regex(TIME), + endTime: z.string().regex(TIME), + breakMinutes: z.number().int().min(0).max(720).default(0), + graceInMinutes: z.number().int().min(0).max(180).default(10), + graceOutMinutes: z.number().int().min(0).max(180).default(10), + active: z.boolean().default(true), +}); + +export type ShiftWrite = z.infer; + +function toMinutes(hhmm: string): number { + const [h, m] = hhmm.split(":").map(Number); + return h * 60 + m; +} + +/** Worked span in minutes. end<=start wraps past midnight; start==end means 24h. */ +export function shiftDurationMinutes(start: string, end: string): number { + const d = toMinutes(end) - toMinutes(start); + return d <= 0 ? d + 1440 : d; +} + +/** True when the shift crosses midnight (night shifts and 24-hour shifts). */ +export function shiftCrossesMidnight(start: string, end: string): boolean { + return toMinutes(end) <= toMinutes(start); +} + +interface ShiftDoc { + companyId: string; + name: string; + code: string; + startTime: string; + endTime: string; + breakMinutes: number; + graceInMinutes: number; + graceOutMinutes: number; + isNightShift: boolean; + active: boolean; + updatedAt: Timestamp; +} + +export function shiftToDto(id: string, d: ShiftDoc): Record { + return { + id, + companyId: d.companyId, + name: d.name, + code: d.code, + startTime: d.startTime, + endTime: d.endTime, + breakMinutes: d.breakMinutes, + graceInMinutes: d.graceInMinutes, + graceOutMinutes: d.graceOutMinutes, + isNightShift: d.isNightShift, + active: d.active, + updatedAt: toIso(d.updatedAt), + }; +} + +export async function listShifts(cid: string): Promise[]> { + const snap = await tenant(cid, "shifts").limit(200).get(); + return snap.docs + .map((doc) => shiftToDto(doc.id, doc.data() as ShiftDoc)) + .sort((a, b) => String(a.startTime).localeCompare(String(b.startTime))); +} + +function buildShiftDoc(cid: string, payload: ShiftWrite): ShiftDoc { + return { + companyId: cid, + name: payload.name, + code: payload.code, + startTime: payload.startTime, + endTime: payload.endTime, + breakMinutes: payload.breakMinutes, + graceInMinutes: payload.graceInMinutes, + graceOutMinutes: payload.graceOutMinutes, + isNightShift: shiftCrossesMidnight(payload.startTime, payload.endTime), + active: payload.active, + updatedAt: nowTimestamp(), + }; +} + +export async function createShift( + cid: string, + payload: ShiftWrite, + actorId: string, + roles: string[], +): Promise> { + const id = ulid(); + const doc = buildShiftDoc(cid, payload); + await tenant(cid, "shifts").doc(id).set(doc); + await audit(cid, { + actorId, + actorRole: roles.join(","), + action: "shift.create", + resourceType: "shifts", + resourceId: id, + after: { name: payload.name, startTime: payload.startTime, endTime: payload.endTime }, + }); + return shiftToDto(id, doc); +} + +export async function updateShift( + cid: string, + id: string, + payload: ShiftWrite, + actorId: string, + roles: string[], +): Promise> { + const ref = tenant(cid, "shifts").doc(id); + if (!(await ref.get()).exists) { + throw ApiError.notFound("Shift not found"); + } + const doc = buildShiftDoc(cid, payload); + await ref.set(doc); + await audit(cid, { + actorId, + actorRole: roles.join(","), + action: "shift.update", + resourceType: "shifts", + resourceId: id, + after: { name: payload.name, active: payload.active }, + }); + return shiftToDto(id, doc); +} + +// ---------------------------------------------------------------- roster + +export const rosterAssignSchema = z.object({ + employeeIds: z.array(z.string().min(1)).min(1).max(200), + shiftId: z.string().min(1), + from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + branchId: z.string().nullish(), +}); + +export type RosterAssign = z.infer; + +function dateRange(from: string, to: string): string[] { + const out: string[] = []; + const start = new Date(`${from}T00:00:00Z`); + const end = new Date(`${to}T00:00:00Z`); + for (const d = start; d <= end; d.setUTCDate(d.getUTCDate() + 1)) { + out.push(d.toISOString().slice(0, 10)); + } + return out; +} + +/** + * Assigns one shift to a set of employees across a date range. One assignment + * per employee/day, keyed `${employeeId}_${date}` so re-running is idempotent + * (a day's assignment is replaced, never duplicated). + */ +export async function assignRoster( + cid: string, + payload: RosterAssign, + actorId: string, + roles: string[], +): Promise<{ created: number }> { + const to = payload.to ?? payload.from; + if (to < payload.from) { + throw ApiError.validation("`to` must be on or after `from`"); + } + const days = dateRange(payload.from, to); + if (days.length > 62) { + throw ApiError.validation("Roster range cannot exceed 62 days"); + } + const total = days.length * payload.employeeIds.length; + if (total > 1000) { + throw ApiError.validation("Too many assignments in one request (max 1000)"); + } + + const shiftSnap = await tenant(cid, "shifts").doc(payload.shiftId).get(); + if (!shiftSnap.exists) { + throw ApiError.notFound("Shift not found"); + } + + const now = nowTimestamp(); + const col = tenant(cid, "shiftAssignments"); + let batch = db.batch(); + let ops = 0; + for (const employeeId of payload.employeeIds) { + for (const date of days) { + batch.set(col.doc(`${employeeId}_${date}`), { + companyId: cid, + employeeId, + shiftId: payload.shiftId, + date, + branchId: payload.branchId ?? null, + source: "ROSTER", + updatedAt: now, + }); + if (++ops === 450) { + await batch.commit(); + batch = db.batch(); + ops = 0; + } + } + } + if (ops > 0) { + await batch.commit(); + } + + await audit(cid, { + actorId, + actorRole: roles.join(","), + action: "roster.assign", + resourceType: "shiftAssignments", + resourceId: payload.shiftId, + after: { employees: payload.employeeIds.length, from: payload.from, to }, + }); + + return { created: total }; +} + +/** Roster board for one day: every assigned employee joined to their shift. */ +export async function rosterForDate( + cid: string, + date: string, +): Promise[]> { + const [assignmentsSnap, shiftsSnap, employeesSnap] = await Promise.all([ + tenant(cid, "shiftAssignments").where("date", "==", date).limit(500).get(), + tenant(cid, "shifts").get(), + tenant(cid, "employees").where("status", "==", "ACTIVE").limit(500).get(), + ]); + + const shiftName = new Map(); + for (const doc of shiftsSnap.docs) { + shiftName.set(doc.id, (doc.data() as { name?: string }).name ?? doc.id); + } + const empName = new Map(); + for (const doc of employeesSnap.docs) { + const e = doc.data() as { firstName?: string; lastName?: string }; + empName.set(doc.id, `${e.firstName ?? ""} ${e.lastName ?? ""}`.trim()); + } + + return assignmentsSnap.docs.map((doc) => { + const a = doc.data() as { employeeId: string; shiftId: string; branchId?: string | null }; + return { + id: doc.id, + employeeId: a.employeeId, + employeeName: empName.get(a.employeeId) ?? a.employeeId, + shiftId: a.shiftId, + shiftName: shiftName.get(a.shiftId) ?? a.shiftId, + branchId: a.branchId ?? null, + date, + }; + }); +} diff --git a/backend/functions/src/services/signup.integration.test.ts b/backend/functions/src/services/signup.integration.test.ts new file mode 100644 index 0000000..d99736f --- /dev/null +++ b/backend/functions/src/services/signup.integration.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect } from "vitest"; +import { getAuth } from "firebase-admin/auth"; +import { db, tenant } from "../lib/firestore"; +import { companySignupSchema, provisionCompany } from "./signup"; + +/** + * Provisioning wrote the company, branch, shift, employee and leave data one + * document at a time and created the Firebase Auth login LAST. A duplicate + * email — or a password Firebase rejected — therefore left a fully-formed + * company that nobody could ever sign in to, and nothing cleaned it up. + * + * Skipped unless the Firestore and Auth emulators are running. + */ + +const EMULATOR = + Boolean(process.env.FIRESTORE_EMULATOR_HOST) && + Boolean(process.env.FIREBASE_AUTH_EMULATOR_HOST); + +let seq = 0; + +function signup(over: Record = {}) { + seq += 1; + return companySignupSchema.parse({ + companyName: "Kabul Traders", + adminFirstName: "Ahmad", + adminLastName: "Karimi", + email: `admin_${Date.now()}_${seq}@example.com`, + password: "Passw0rd!", + ...over, + }); +} + +/** Companies are top-level, so an orphan is found by scanning for the name. */ +async function companiesNamed(name: string): Promise { + const snap = await db.collection("companies").where("name", "==", name).get(); + return snap.docs.map((d) => d.id); +} + +describe.skipIf(!EMULATOR)("company signup", () => { + it("provisions a workspace the founding admin can sign in to", async () => { + const input = signup(); + + const { companyId, employeeId } = await provisionCompany(input); + + const company = await db.collection("companies").doc(companyId).get(); + expect(company.exists).toBe(true); + + const employee = await tenant(companyId, "employees").doc(employeeId).get(); + expect(employee.data()?.email).toBe(input.email); + + // The workspace is usable out of the box. + const [branches, shifts, leaveTypes] = await Promise.all([ + tenant(companyId, "branches").get(), + tenant(companyId, "shifts").get(), + tenant(companyId, "leaveTypes").get(), + ]); + expect(branches.size).toBe(1); + expect(shifts.size).toBe(1); + expect(leaveTypes.size).toBe(2); + }); + + it("sets a construction company up for construction", async () => { + // The whole point of asking: a firm with several sites gets fences on, + // and a grace wide enough that arriving at a site is not arriving late. + const { companyId } = await provisionCompany(signup({ businessType: "CONSTRUCTION" })); + + const settings = (await db.collection("companies").doc(companyId).get()).data()!.settings; + expect(settings.features.geofencing).toBe(true); + expect(settings.policies.lateGraceMinutes).toBe(20); + expect(settings.profile.businessType).toBe("CONSTRUCTION"); + }); + + it("does not fence a tailoring workshop, or point a camera at it", async () => { + const { companyId } = await provisionCompany(signup({ businessType: "TAILORING" })); + + const settings = (await db.collection("companies").doc(companyId).get()).data()!.settings; + expect(settings.features.geofencing).toBe(false); + expect(settings.features.faceRecognition).toBe(false); + // Everything the preset was silent about is untouched. + expect(settings.features.payroll).toBe(true); + expect(settings.policies.weekendDays).toEqual([5]); + }); + + it("still provisions a workspace when the type is unknown or absent", async () => { + // Nobody fails to sign up because of a dropdown. + const plain = await provisionCompany(signup()); + const odd = await provisionCompany(signup({ businessType: "A_TYPE_WE_RETIRED" })); + + for (const { companyId } of [plain, odd]) { + const settings = (await db.collection("companies").doc(companyId).get()).data()!.settings; + expect(settings.features.geofencing).toBe(true); + expect(settings.policies.standardDailyMinutes).toBe(480); + expect(settings.profile.businessType).toBeNull(); + } + }); + + it("creates the login unverified and gated", async () => { + const input = signup(); + + const { companyId, employeeId } = await provisionCompany(input); + + const user = await getAuth().getUser(employeeId); + // Whoever filled in the form asserted this address; only the link Firebase + // mails to it proves they own it. + expect(user.emailVerified).toBe(false); + expect(user.customClaims).toMatchObject({ + cid: companyId, + eid: employeeId, + r: ["COMPANY_ADMIN"], + sv: true, + }); + }); + + it("normalises the email so the same address cannot be taken twice", async () => { + // Unique per run, like every other address in this file: a fixed one is + // still registered on the next run against the same emulator, so the first + // provisionCompany below fails and the test only ever passes once. + const local = `Mixed.Case_${Date.now()}_${seq++}`; + const input = signup({ email: `${local}@Example.COM` }); + expect(input.email).toBe(`${local.toLowerCase()}@example.com`); + + await provisionCompany(input); + + await expect( + provisionCompany(signup({ email: `${local.toUpperCase()}@example.com` })), + ).rejects.toMatchObject({ code: "CONFLICT" }); + }); + + it("leaves no orphaned company when the email is already registered", async () => { + const first = signup({ companyName: "Orphan Test A" }); + await provisionCompany(first); + + await expect( + provisionCompany(signup({ companyName: "Orphan Test B", email: first.email })), + ).rejects.toMatchObject({ code: "CONFLICT" }); + + // The refused attempt must not have left a company behind. + expect(await companiesNamed("Orphan Test B")).toHaveLength(0); + }); + + it("leaves no orphaned company when the password is rejected", async () => { + // Firebase requires at least six characters; the schema allows eight, so a + // short-but-schema-valid password is not reachable — use the API directly. + await expect( + provisionCompany({ + ...signup({ companyName: "Orphan Test C" }), + password: "x", + }), + ).rejects.toThrow(); + + expect(await companiesNamed("Orphan Test C")).toHaveLength(0); + }); + + it("creates only one company when the same email is submitted twice at once", async () => { + // The name is unique per run for the same reason the addresses are: the + // assertion counts companies by name, and a fixed one accumulates across + // runs against a persistent emulator until the count is never 1 again. + const name = `Race Test ${Date.now()}_${seq++}`; + const input = signup({ companyName: name }); + + const results = await Promise.allSettled([ + provisionCompany({ ...input }), + provisionCompany({ ...input }), + ]); + + expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1); + expect(await companiesNamed(name)).toHaveLength(1); + }); +}); diff --git a/backend/functions/src/services/signup.ts b/backend/functions/src/services/signup.ts new file mode 100644 index 0000000..cd9d378 --- /dev/null +++ b/backend/functions/src/services/signup.ts @@ -0,0 +1,224 @@ +import { getAuth } from "firebase-admin/auth"; +import { z } from "zod"; +import { ApiError, ErrorCodes } from "../lib/errors"; +import { db, nowTimestamp, tenant } from "../lib/firestore"; +import { ulid } from "../lib/ids"; +import { solarHolidaysFor } from "./calendar"; +import { currentShamsiMonth } from "../lib/shamsi"; +import { findBusinessType, settingsForBusinessType } from "./businessTypes"; + +export const companySignupSchema = z.object({ + companyName: z.string().min(2).max(120), + adminFirstName: z.string().min(1).max(80), + adminLastName: z.string().min(1).max(80), + email: z.string().email().transform((v) => v.trim().toLowerCase()), + password: z.string().min(8).max(100), + timezone: z.string().default("Asia/Kabul"), + currency: z.string().length(3).default("AFN"), + /** + * What kind of work the company does. Optional, and an unrecognised value is + * ignored rather than refused — nobody should fail to sign up because of a + * dropdown, and the list will change. + */ + businessType: z.string().max(40).nullish(), +}); + +export type CompanySignup = z.infer; + +export interface SignupResult { + companyId: string; + employeeId: string; +} + +/** + * Provisions a brand-new tenant: a company, its head-office branch, the founding + * COMPANY_ADMIN (both an employee record and a Firebase Auth user with tenant + * claims), and sensible default leave types. This is the "each company gets its + * own workspace" entry point. + * + * Ordering matters. The login is created FIRST and every tenant document goes + * in one atomic batch. Provisioning used to write the company, branch, shift, + * employee and leave data one document at a time and create the login LAST, so + * a duplicate email or a password Firebase rejected left a fully-formed company + * that nobody could ever sign in to, and nothing cleaned it up. The old + * getUserByEmail pre-check was also a race: two concurrent signups for the same + * address both passed it, and the loser orphaned a company. + * + * The account stays gated until the address is verified — see the `sv` claim + * and requireAuth. Rate limiting lives on the route. + */ +export async function provisionCompany(input: CompanySignup): Promise { + const auth = getAuth(); + + const companyId = ulid(); + const employeeId = ulid(); + const branchId = ulid(); + const now = nowTimestamp(); + const joinDate = new Date().toISOString().slice(0, 10); + const periodYear = new Date().getUTCFullYear(); + + // Firebase is the authority on whether the address is free and the password + // acceptable, so let it decide before anything else is written. + await auth + .createUser({ + uid: employeeId, + email: input.email, + password: input.password, + displayName: `${input.adminFirstName} ${input.adminLastName}`.trim(), + // Asserted by whoever filled in the form; proven only by the link that + // Firebase mails to the address itself. + emailVerified: false, + }) + .catch((err: unknown) => { + const code = (err as { code?: string }).code; + if (code === "auth/email-already-exists" || code === "auth/uid-already-exists") { + throw new ApiError(409, ErrorCodes.CONFLICT, "An account with this email already exists"); + } + if (code === "auth/invalid-password") { + throw ApiError.validation("Password is too weak", { + password: "Use at least 8 characters", + }); + } + throw err; + }); + + try { + await auth.setCustomUserClaims(employeeId, { + cid: companyId, + eid: employeeId, + r: ["COMPANY_ADMIN"], + b: [branchId], + // Self-signup: gated until the email address is verified. Accounts an + // admin creates for staff never carry this, so they are unaffected. + sv: true, + }); + + // One batch: either the whole workspace exists or none of it does. + const batch = db.batch(); + + // 1. Company (with default settings: all core modules on) + batch.set(db.collection("companies").doc(companyId), { + name: input.companyName, + legalName: input.companyName, + timezone: input.timezone, + currency: input.currency, + status: "ACTIVE", + plan: "FREE", + settings: { + ...settingsForBusinessType(input.businessType), + profile: { + currency: input.currency, + timezone: input.timezone, + businessType: findBusinessType(input.businessType)?.id ?? null, + }, + }, + createdAt: now, + updatedAt: now, + }); + + // A starter day shift so the roster works out of the box. + batch.set(tenant(companyId, "shifts").doc("default-day"), { + companyId, + name: "شیفت روز", + code: "DAY", + startTime: "08:00", + endTime: "16:00", + breakMinutes: 60, + graceInMinutes: 10, + graceOutMinutes: 10, + isNightShift: false, + active: true, + updatedAt: now, + }); + + // 2. Head-office branch (managers configure geofences/shifts on it later) + batch.set(tenant(companyId, "branches").doc(branchId), { + companyId, + name: "دفتر مرکزی", + code: "HQ", + address: null, + latitude: null, + longitude: null, + radiusMeters: null, + timezone: input.timezone, + status: "ACTIVE", + updatedAt: now, + }); + + // 3. Founding admin employee + batch.set(tenant(companyId, "employees").doc(employeeId), { + companyId, + employeeCode: "E-001", + firstName: input.adminFirstName, + lastName: input.adminLastName, + email: input.email, + phone: null, + avatarUrl: null, + branchId, + departmentId: null, + positionId: null, + managerId: null, + employmentType: "FULL_TIME", + joinDate, + status: "ACTIVE", + updatedAt: now, + }); + + // 4. Default leave types so leave works out of the box + const leaveTypes = [ + { id: "annual", name: "رخصتی سالانه", code: "ANNUAL", colorHex: "#2E7D32", entitled: 20 }, + { id: "sick", name: "رخصتی مریضی", code: "SICK", colorHex: "#B3261E", entitled: 10 }, + ]; + for (const lt of leaveTypes) { + batch.set(tenant(companyId, "leaveTypes").doc(lt.id), { + companyId, + name: lt.name, + code: lt.code, + colorHex: lt.colorHex, + isPaid: true, + requiresAttachment: false, + active: true, + // The yearly grant lives on the type so every employee added later gets + // the same entitlement without anyone re-entering it. + defaultEntitlementDays: lt.entitled, + updatedAt: now, + }); + batch.set(tenant(companyId, "leaveBalances").doc(`${employeeId}_${lt.id}_${periodYear}`), { + employeeId, + leaveTypeId: lt.id, + periodYear, + entitledDays: lt.entitled, + accruedDays: 0, + usedDays: 0, + carriedOverDays: 0, + pendingDays: 0, + updatedAt: now, + }); + } + + // 5. A working calendar, so the first payroll run knows which days were + // meant to be worked — without it every weekend and holiday reads as + // absence. Only the Solar Hijri holidays are generated: Eid and the other + // lunar dates are announced by moon sighting and are added from the portal. + // Two years are seeded so a company signing up late is not left with an + // empty calendar come Hamal. + // periodYear above is the Gregorian year, which the leave balances are + // keyed by; holidays are fixed in the Solar Hijri calendar and need its + // year instead. Passing 2026 here would land the dates six centuries out. + const shamsiYear = currentShamsiMonth().year; + for (const year of [shamsiYear, shamsiYear + 1]) { + for (const h of solarHolidaysFor(year)) { + batch.set(tenant(companyId, "holidays").doc(h.date), { ...h, updatedAt: now }); + } + } + + await batch.commit(); + } catch (err) { + // The batch is all-or-nothing and nothing tenant-side is reachable without + // the claims, so removing the login removes every trace of the attempt. + await auth.deleteUser(employeeId).catch(() => undefined); + throw err; + } + + return { companyId, employeeId }; +} diff --git a/backend/functions/src/services/vendor.ts b/backend/functions/src/services/vendor.ts new file mode 100644 index 0000000..194d34a --- /dev/null +++ b/backend/functions/src/services/vendor.ts @@ -0,0 +1,87 @@ +import { db, tenant, toIso } from "../lib/firestore"; +import { DEFAULT_LICENSE, isDeviceActive } from "./license"; +import type { DeviceDoc, License } from "./license"; + +/** + * What the vendor can see across every customer. + * + * Deliberately company-level only: name, licence, how many seats are in use, + * how many people are on the books. No attendance, no payslips, no employee + * records. The privacy notice tells every customer that Linumic is a processor + * acting on their written instruction, and a console that browsed their staff + * would make that untrue — so the reach of this surface is bounded here, in + * the queries, rather than by remembering not to look. + */ + +export interface CompanySummary { + companyId: string; + name: string; + status: string; + license: License; + /** Seats occupied by a registered, non-revoked device. */ + devicesInUse: number; + employeeCount: number; + /** Null when the licence never expires. */ + daysUntilExpiry: number | null; + createdAt: string | null; + deletion: { status: string; purgeAfter: string | null } | null; +} + +function daysBetween(fromIso: string, toIsoDate: string): number { + const a = Date.parse(`${fromIso}T00:00:00Z`); + const b = Date.parse(`${toIsoDate}T00:00:00Z`); + return Math.round((b - a) / 86_400_000); +} + +async function summarise( + doc: FirebaseFirestore.QueryDocumentSnapshot, + todayIso: string, +): Promise { + const d = doc.data(); + const license: License = { ...DEFAULT_LICENSE, ...(d.license ?? {}) }; + + // count() aggregations rather than reading the documents: the vendor needs + // the number, not the people. + const [deviceSnap, employeeAgg] = await Promise.all([ + tenant(doc.id, "devices").limit(1000).get(), + tenant(doc.id, "employees").where("status", "==", "ACTIVE").count().get(), + ]); + + const deletion = d.deletion as { status?: string; purgeAfter?: string } | undefined; + + return { + companyId: doc.id, + name: (d.name as string) ?? "(unnamed)", + status: (d.status as string) ?? "ACTIVE", + license, + devicesInUse: deviceSnap.docs.filter((x) => isDeviceActive(x.data() as DeviceDoc)).length, + employeeCount: employeeAgg.data().count, + daysUntilExpiry: license.expiresAt ? daysBetween(todayIso, license.expiresAt) : null, + createdAt: toIso(d.createdAt ?? null), + deletion: deletion?.status + ? { status: deletion.status, purgeAfter: deletion.purgeAfter ?? null } + : null, + }; +} + +export async function listCompanies(todayIso: string): Promise { + const snap = await db.collection("companies").limit(500).get(); + const rows = await Promise.all(snap.docs.map((d) => summarise(d, todayIso))); + // Whatever needs attention soonest, first: expired, then expiring, then the + // rest. A vendor opening this wants to know what is about to break. + return rows.sort((a, b) => { + const av = a.daysUntilExpiry ?? Number.MAX_SAFE_INTEGER; + const bv = b.daysUntilExpiry ?? Number.MAX_SAFE_INTEGER; + if (av !== bv) return av - bv; + return a.name.localeCompare(b.name); + }); +} + +export async function getCompany( + companyId: string, + todayIso: string, +): Promise { + const doc = await db.collection("companies").doc(companyId).get(); + if (!doc.exists) return null; + return summarise(doc as FirebaseFirestore.QueryDocumentSnapshot, todayIso); +} diff --git a/backend/functions/src/services/work.test.ts b/backend/functions/src/services/work.test.ts new file mode 100644 index 0000000..3f0160a --- /dev/null +++ b/backend/functions/src/services/work.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from "vitest"; +import { + addDays, + assertSpan, + canSetStatus, + expandAssignees, + nextWorkingDay, + taskRunsOn, +} from "./work"; + +/** + * The scheduling rules, without a database. + * + * These are the parts that decide what an employee is told he is doing + * tomorrow, so they are tested against the Afghan working week (Friday off) + * rather than a Monday-to-Friday one. + */ + +const FRIDAY_OFF = [5]; // ISO weekday: Friday +const NO_HOLIDAYS = new Set(); + +describe("expandAssignees", () => { + it("puts a crew and the extra man on the same task", () => { + expect(expandAssignees(["e_spark"], ["e_ali", "e_omar"])).toEqual([ + "e_ali", + "e_omar", + "e_spark", + ]); + }); + + it("counts somebody named twice once", () => { + // Otherwise array-contains returns his task twice and he sees it twice. + expect(expandAssignees(["e_ali"], ["e_ali", "e_omar"])).toEqual(["e_ali", "e_omar"]); + }); + + it("is order-independent, so the same assignment compares equal", () => { + expect(expandAssignees(["b", "a"])).toEqual(expandAssignees(["a", "b"])); + }); + + it("an individual assignment is just a team of one", () => { + expect(expandAssignees(["e_ali"])).toEqual(["e_ali"]); + }); +}); + +describe("taskRunsOn", () => { + const span = { startDate: "2026-09-05", endDate: "2026-09-09", assigneeIds: [] }; + + it("includes both endpoints", () => { + expect(taskRunsOn(span, "2026-09-05")).toBe(true); + expect(taskRunsOn(span, "2026-09-09")).toBe(true); + }); + + it("excludes the days either side", () => { + expect(taskRunsOn(span, "2026-09-04")).toBe(false); + expect(taskRunsOn(span, "2026-09-10")).toBe(false); + }); + + it("a single-day task runs on its one day", () => { + const day = { startDate: "2026-09-07", endDate: "2026-09-07", assigneeIds: [] }; + expect(taskRunsOn(day, "2026-09-07")).toBe(true); + expect(taskRunsOn(day, "2026-09-08")).toBe(false); + }); +}); + +describe("nextWorkingDay", () => { + it("is tomorrow on an ordinary day", () => { + // 2026-09-07 is a Monday. + expect(nextWorkingDay("2026-09-07", FRIDAY_OFF, NO_HOLIDAYS)).toBe("2026-09-08"); + }); + + it("skips Friday, so Thursday's answer is Saturday", () => { + // This is the case the feature exists for. 2026-09-10 is a Thursday; a + // literal "tomorrow" would show an empty Friday and the employee would + // conclude he has nothing on. + expect(nextWorkingDay("2026-09-10", FRIDAY_OFF, NO_HOLIDAYS)).toBe("2026-09-12"); + }); + + it("skips a holiday too", () => { + expect(nextWorkingDay("2026-09-07", FRIDAY_OFF, new Set(["2026-09-08"]))).toBe("2026-09-09"); + }); + + it("skips a holiday that falls on the day after a weekend", () => { + expect( + nextWorkingDay("2026-09-10", FRIDAY_OFF, new Set(["2026-09-12", "2026-09-13"])), + ).toBe("2026-09-14"); + }); + + it("gives up rather than guessing when the company is closed for a fortnight", () => { + const shut = new Set( + Array.from({ length: 20 }, (_, i) => addDays("2026-09-07", i + 1)), + ); + expect(nextWorkingDay("2026-09-07", FRIDAY_OFF, shut)).toBeNull(); + }); + + it("crosses a month and a year boundary", () => { + expect(nextWorkingDay("2026-09-30", FRIDAY_OFF, NO_HOLIDAYS)).toBe("2026-10-01"); + // 2027-01-01 is itself a Friday, so the answer from Thursday the 31st is + // Saturday the 2nd — the weekend rule wins over the year boundary. + expect(nextWorkingDay("2026-12-31", FRIDAY_OFF, NO_HOLIDAYS)).toBe("2027-01-02"); + }); + + it("honours a company that rests on Friday and Saturday", () => { + // 2026-09-10 is a Thursday; with both days off the answer is Sunday. + expect(nextWorkingDay("2026-09-10", [5, 6], NO_HOLIDAYS)).toBe("2026-09-13"); + }); +}); + +describe("addDays", () => { + it("crosses month ends", () => { + expect(addDays("2026-09-30", 1)).toBe("2026-10-01"); + }); + + it("crosses a leap day", () => { + expect(addDays("2028-02-28", 1)).toBe("2028-02-29"); + }); + + it("goes backwards", () => { + expect(addDays("2026-01-01", -1)).toBe("2025-12-31"); + }); +}); + +describe("assertSpan", () => { + it("accepts a single day and a normal span", () => { + expect(() => assertSpan("2026-09-07", "2026-09-07")).not.toThrow(); + expect(() => assertSpan("2026-09-07", "2026-09-20")).not.toThrow(); + }); + + it("refuses an end before the start", () => { + expect(() => assertSpan("2026-09-07", "2026-09-06")).toThrow(/end date is before/i); + }); + + it("refuses a task that would sit on every day for years", () => { + expect(() => assertSpan("2026-01-01", "2028-01-01")).toThrow(/more than a year/i); + }); +}); + +describe("canSetStatus", () => { + const task = { assigneeIds: ["e_ali", "e_omar"] }; + + it("lets the man doing the work say how it is going", () => { + expect(canSetStatus(task, "e_ali", false)).toBe(true); + }); + + it("does not let a colleague close somebody else's work", () => { + expect(canSetStatus(task, "e_fatima", false)).toBe(false); + }); + + it("lets a planner close anything", () => { + expect(canSetStatus(task, "e_fatima", true)).toBe(true); + }); +}); diff --git a/backend/functions/src/services/work.ts b/backend/functions/src/services/work.ts new file mode 100644 index 0000000..0f207b3 --- /dev/null +++ b/backend/functions/src/services/work.ts @@ -0,0 +1,821 @@ +import { z } from "zod"; +import { ApiError } from "../lib/errors"; +import { audit, nowTimestamp, tenant, toIso } from "../lib/firestore"; +import { ulid } from "../lib/ids"; +import { classifyDay, eachDate, holidaySet } from "./calendar"; +import { getSettings } from "./settings"; +import type { Timestamp } from "firebase-admin/firestore"; + +/** + * Who is doing which part of the company's work, on which day. + * + * Attendance answers "was he here". This answers "and what was he meant to be + * doing" — the question a foreman on a building site is actually asked every + * morning, and the one thing the app could not tell an employee. + * + * Three collections, deliberately shallow: + * + * projects what the company is building. A contract, a site, a phase. + * projectTeams a named group of people. Not a department: the plastering + * crew is drawn from three departments and changes next month. + * tasks one piece of work, on a date range, for one or more people. + * + * A task is assigned to PEOPLE, never to a team. Assigning to a team expands to + * its members at write time and stores the ids. This costs one denormalisation + * and buys two things worth more than it: an employee's own query stays a single + * array-contains (no second read to work out which teams he is in), and moving + * somebody out of a team tomorrow does not silently rewrite who was responsible + * for yesterday's work. + * + * Everything above the database line in this file is pure, so the scheduling + * rules can be tested without an emulator. + */ + +// --------------------------------------------------------------------- types + +export const TASK_STATUSES = ["PLANNED", "IN_PROGRESS", "DONE", "BLOCKED"] as const; +export type TaskStatus = (typeof TASK_STATUSES)[number]; + +export const TASK_PRIORITIES = ["LOW", "NORMAL", "HIGH"] as const; +export type TaskPriority = (typeof TASK_PRIORITIES)[number]; + +export const PROJECT_STATUSES = ["PLANNED", "ACTIVE", "PAUSED", "DONE"] as const; + +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; + +/** The span-and-assignees shape the pure rules below need. Nothing more. */ +export interface TaskSpan { + startDate: string; + endDate: string; + assigneeIds: string[]; + status?: TaskStatus; +} + +// ---------------------------------------------------------------- pure rules + +/** + * The people a task lands on. + * + * A team plus named extras is the normal case on a site — the crew, plus the + * electrician who joins them for the day. Sorted and de-duplicated so that + * assigning the same person twice does not make the array-contains query + * return him twice, and so two identical assignments compare equal. + */ +export function expandAssignees( + explicitIds: readonly string[], + teamMemberIds: readonly string[] = [], +): string[] { + return [...new Set([...teamMemberIds, ...explicitIds])].sort(); +} + +/** True when [dateIso] falls inside the task's span, endpoints included. */ +export function taskRunsOn(task: TaskSpan, dateIso: string): boolean { + return task.startDate <= dateIso && dateIso <= task.endDate; +} + +/** + * The next day somebody is actually expected in, starting the day after + * [fromIso]. + * + * "What am I on tomorrow" asked on a Thursday means Saturday in Afghanistan, + * not Friday. Answering with an empty Friday would be technically correct and + * useless — the employee would conclude he has nothing on, and find out + * otherwise when he arrives. + * + * Bounded at two weeks: a company that has closed for longer has no next + * working day worth showing. + */ +export function nextWorkingDay( + fromIso: string, + weekendDays: number[], + holidays: ReadonlySet, +): string | null { + const horizon = addDays(fromIso, 14); + for (const date of eachDate(addDays(fromIso, 1), horizon)) { + if (classifyDay(date, weekendDays, holidays) === "WORKING") return date; + } + return null; +} + +export function addDays(dateIso: string, days: number): string { + const t = new Date(`${dateIso}T00:00:00Z`).getTime() + days * 86_400_000; + return new Date(t).toISOString().slice(0, 10); +} + +/** + * Validates a span. Kept separate from the zod schema because zod cannot + * express "end is not before start" without a refinement that reports against + * the wrong field. + */ +export function assertSpan(startDate: string, endDate: string): void { + if (endDate < startDate) { + throw ApiError.validation("The end date is before the start date", { + endDate: "Must not be earlier than the start date", + }); + } + // A task spanning years is a project, not a task, and it would sit at the top + // of every employee's day forever. + if (eachDate(startDate, endDate).length > 366) { + throw ApiError.validation("A task cannot span more than a year", { + endDate: "Too far from the start date", + }); + } +} + +/** + * Whether [employeeId] may move this task's status. + * + * The assignee owns the status of his own work — that is the whole point of + * putting it on his phone. Anyone with work:write owns everything else. + */ +export function canSetStatus( + task: { assigneeIds: string[] }, + employeeId: string, + hasWorkWrite: boolean, +): boolean { + return hasWorkWrite || task.assigneeIds.includes(employeeId); +} + +// ------------------------------------------------------------------- schemas + +export const projectWriteSchema = z.object({ + name: z.string().min(1).max(120), + code: z.string().min(1).max(24), + description: z.string().max(2000).nullish(), + branchId: z.string().min(1).max(64).nullish(), + managerId: z.string().min(1).max(64).nullish(), + status: z.enum(PROJECT_STATUSES).default("ACTIVE"), + startDate: z.string().regex(ISO_DATE).nullish(), + endDate: z.string().regex(ISO_DATE).nullish(), +}); +export type ProjectWrite = z.infer; + +export const teamWriteSchema = z.object({ + name: z.string().min(1).max(120), + projectId: z.string().min(1).max(64).nullish(), + leadId: z.string().min(1).max(64).nullish(), + memberIds: z.array(z.string().min(1).max(64)).max(500).default([]), + active: z.boolean().default(true), +}); +export type TeamWrite = z.infer; + +export const taskCreateSchema = z.object({ + projectId: z.string().min(1).max(64), + title: z.string().min(1).max(200), + detail: z.string().max(4000).nullish(), + location: z.string().max(200).nullish(), + startDate: z.string().regex(ISO_DATE), + /** Omitted means a single day — the common case. */ + endDate: z.string().regex(ISO_DATE).nullish(), + priority: z.enum(TASK_PRIORITIES).default("NORMAL"), + /** Assign to a whole crew; expanded to its members at write time. */ + teamId: z.string().min(1).max(64).nullish(), + /** Assign to named people, with or without a team. */ + assigneeIds: z.array(z.string().min(1).max(64)).max(500).default([]), +}); +export type TaskCreate = z.infer; + +export const taskUpdateSchema = taskCreateSchema.partial().extend({ + status: z.enum(TASK_STATUSES).optional(), +}); +export type TaskUpdate = z.infer; + +export const taskStatusSchema = z.object({ + status: z.enum(TASK_STATUSES), + note: z.string().max(1000).nullish(), +}); + +// ------------------------------------------------------------------ projects + +interface ProjectDoc { + companyId: string; + name: string; + code: string; + description: string | null; + branchId: string | null; + managerId: string | null; + status: string; + startDate: string | null; + endDate: string | null; + createdBy: string; + createdAt: Timestamp; + updatedAt: Timestamp; +} + +function projectToDto(id: string, d: ProjectDoc): Record { + return { + id, + companyId: d.companyId, + name: d.name, + code: d.code, + description: d.description ?? null, + branchId: d.branchId ?? null, + managerId: d.managerId ?? null, + status: d.status, + startDate: d.startDate ?? null, + endDate: d.endDate ?? null, + updatedAt: toIso(d.updatedAt), + }; +} + +export async function listProjects(cid: string): Promise[]> { + const snap = await tenant(cid, "projects").limit(300).get(); + return snap.docs + .map((doc) => projectToDto(doc.id, doc.data() as ProjectDoc)) + .sort((a, b) => String(a.name).localeCompare(String(b.name))); +} + +export async function createProject( + cid: string, + payload: ProjectWrite, + actorId: string, + actorRoles: string[], +): Promise> { + if (payload.startDate && payload.endDate) assertSpan(payload.startDate, payload.endDate); + const id = ulid(); + const now = nowTimestamp(); + const doc: ProjectDoc = { + companyId: cid, + name: payload.name, + code: payload.code, + description: payload.description ?? null, + branchId: payload.branchId ?? null, + managerId: payload.managerId ?? null, + status: payload.status, + startDate: payload.startDate ?? null, + endDate: payload.endDate ?? null, + createdBy: actorId, + createdAt: now, + updatedAt: now, + }; + await tenant(cid, "projects").doc(id).create(doc); + await audit(cid, { + actorId, + actorRole: actorRoles.join(","), + action: "projects.create", + resourceType: "projects", + resourceId: id, + after: { name: doc.name, code: doc.code }, + }); + return projectToDto(id, doc); +} + +export async function updateProject( + cid: string, + id: string, + payload: ProjectWrite, + actorId: string, + actorRoles: string[], +): Promise> { + if (payload.startDate && payload.endDate) assertSpan(payload.startDate, payload.endDate); + const ref = tenant(cid, "projects").doc(id); + const snap = await ref.get(); + if (!snap.exists) throw ApiError.notFound("Project not found"); + const before = snap.data() as ProjectDoc; + + const doc: ProjectDoc = { + ...before, + name: payload.name, + code: payload.code, + description: payload.description ?? null, + branchId: payload.branchId ?? null, + managerId: payload.managerId ?? null, + status: payload.status, + startDate: payload.startDate ?? null, + endDate: payload.endDate ?? null, + updatedAt: nowTimestamp(), + }; + await ref.set(doc); + await audit(cid, { + actorId, + actorRole: actorRoles.join(","), + action: "projects.update", + resourceType: "projects", + resourceId: id, + before: { name: before.name, status: before.status }, + after: { name: doc.name, status: doc.status }, + }); + return projectToDto(id, doc); +} + +/** + * Deleting a project leaves its tasks orphaned, so it is refused while any + * exist. Closing a project is what a finished one wants anyway — the history of + * who built what is the reason to keep it. + */ +export async function deleteProject( + cid: string, + id: string, + actorId: string, + actorRoles: string[], +): Promise { + const open = await tenant(cid, "tasks").where("projectId", "==", id).limit(1).get(); + if (!open.empty) { + throw ApiError.business( + "CONFLICT", + "This project still has work assigned to it. Mark it finished instead of deleting it.", + ); + } + await tenant(cid, "projects").doc(id).delete(); + await audit(cid, { + actorId, + actorRole: actorRoles.join(","), + action: "projects.delete", + resourceType: "projects", + resourceId: id, + }); +} + +// --------------------------------------------------------------------- teams + +interface TeamDoc { + companyId: string; + name: string; + projectId: string | null; + leadId: string | null; + memberIds: string[]; + active: boolean; + createdBy: string; + createdAt: Timestamp; + updatedAt: Timestamp; +} + +function teamToDto(id: string, d: TeamDoc): Record { + return { + id, + companyId: d.companyId, + name: d.name, + projectId: d.projectId ?? null, + leadId: d.leadId ?? null, + memberIds: d.memberIds ?? [], + active: d.active, + updatedAt: toIso(d.updatedAt), + }; +} + +export async function listTeams(cid: string): Promise[]> { + const snap = await tenant(cid, "projectTeams").limit(300).get(); + return snap.docs + .map((doc) => teamToDto(doc.id, doc.data() as TeamDoc)) + .sort((a, b) => String(a.name).localeCompare(String(b.name))); +} + +export async function createTeam( + cid: string, + payload: TeamWrite, + actorId: string, + actorRoles: string[], +): Promise> { + const id = ulid(); + const now = nowTimestamp(); + const doc: TeamDoc = { + companyId: cid, + name: payload.name, + projectId: payload.projectId ?? null, + leadId: payload.leadId ?? null, + memberIds: [...new Set(payload.memberIds)].sort(), + active: payload.active, + createdBy: actorId, + createdAt: now, + updatedAt: now, + }; + await tenant(cid, "projectTeams").doc(id).create(doc); + await audit(cid, { + actorId, + actorRole: actorRoles.join(","), + action: "teams.create", + resourceType: "projectTeams", + resourceId: id, + after: { name: doc.name, members: doc.memberIds.length }, + }); + return teamToDto(id, doc); +} + +export async function updateTeam( + cid: string, + id: string, + payload: TeamWrite, + actorId: string, + actorRoles: string[], +): Promise> { + const ref = tenant(cid, "projectTeams").doc(id); + const snap = await ref.get(); + if (!snap.exists) throw ApiError.notFound("Team not found"); + const before = snap.data() as TeamDoc; + + const doc: TeamDoc = { + ...before, + name: payload.name, + projectId: payload.projectId ?? null, + leadId: payload.leadId ?? null, + memberIds: [...new Set(payload.memberIds)].sort(), + active: payload.active, + updatedAt: nowTimestamp(), + }; + await ref.set(doc); + await audit(cid, { + actorId, + actorRole: actorRoles.join(","), + action: "teams.update", + resourceType: "projectTeams", + resourceId: id, + before: { name: before.name, members: (before.memberIds ?? []).length }, + after: { name: doc.name, members: doc.memberIds.length }, + }); + return teamToDto(id, doc); +} + +/** + * Deleting a team does NOT touch the tasks it was used to assign. Those were + * expanded to people at write time and stay assigned to them — disbanding a + * crew must not quietly unassign tomorrow's work. + */ +export async function deleteTeam( + cid: string, + id: string, + actorId: string, + actorRoles: string[], +): Promise { + await tenant(cid, "projectTeams").doc(id).delete(); + await audit(cid, { + actorId, + actorRole: actorRoles.join(","), + action: "teams.delete", + resourceType: "projectTeams", + resourceId: id, + }); +} + +// --------------------------------------------------------------------- tasks + +interface TaskDoc { + companyId: string; + projectId: string; + projectName: string; + title: string; + detail: string | null; + location: string | null; + startDate: string; + endDate: string; + status: TaskStatus; + priority: TaskPriority; + teamId: string | null; + teamName: string | null; + assigneeIds: string[]; + /** + * Display names, snapshotted at assignment. + * + * The phone pulls only its own employee record, so without this an employee + * would see a team task listing four ids he cannot read. A later rename + * leaves an old task showing the old name, which is the correct trade for + * work that is measured in days. + */ + assigneeNames: string[]; + statusNote: string | null; + completedAt: Timestamp | null; + createdBy: string; + createdAt: Timestamp; + updatedAt: Timestamp; +} + +export function taskToDto(id: string, d: TaskDoc): Record { + return { + id, + companyId: d.companyId, + projectId: d.projectId, + projectName: d.projectName, + title: d.title, + detail: d.detail ?? null, + location: d.location ?? null, + startDate: d.startDate, + endDate: d.endDate, + status: d.status, + priority: d.priority, + teamId: d.teamId ?? null, + teamName: d.teamName ?? null, + assigneeIds: d.assigneeIds ?? [], + assigneeNames: d.assigneeNames ?? [], + statusNote: d.statusNote ?? null, + completedAt: toIso(d.completedAt ?? null), + updatedAt: toIso(d.updatedAt), + }; +} + +/** Names for the ids, in the order given, falling back to the id itself. */ +async function namesFor(cid: string, ids: string[]): Promise { + if (ids.length === 0) return []; + const docs = await Promise.all(ids.map((id) => tenant(cid, "employees").doc(id).get())); + return docs.map((doc, i) => { + const d = doc.data(); + if (!d) return ids[i]; + return `${(d.firstName as string) ?? ""} ${(d.lastName as string) ?? ""}`.trim() || ids[i]; + }); +} + +/** Resolves teamId + explicit ids into the stored assignee list and its labels. */ +async function resolveAssignment( + cid: string, + teamId: string | null, + explicitIds: string[], +): Promise<{ ids: string[]; names: string[]; teamName: string | null }> { + let teamName: string | null = null; + let members: string[] = []; + if (teamId) { + const snap = await tenant(cid, "projectTeams").doc(teamId).get(); + if (!snap.exists) throw ApiError.notFound("Team not found"); + const team = snap.data() as TeamDoc; + teamName = team.name; + members = team.memberIds ?? []; + } + const ids = expandAssignees(explicitIds, members); + if (ids.length === 0) { + throw ApiError.validation("Assign this to somebody", { + assigneeIds: "Choose a person or a team", + }); + } + return { ids, names: await namesFor(cid, ids), teamName }; +} + +export async function createTask( + cid: string, + payload: TaskCreate, + actorId: string, + actorRoles: string[], +): Promise> { + const endDate = payload.endDate ?? payload.startDate; + assertSpan(payload.startDate, endDate); + + const projectSnap = await tenant(cid, "projects").doc(payload.projectId).get(); + if (!projectSnap.exists) throw ApiError.notFound("Project not found"); + const project = projectSnap.data() as ProjectDoc; + + const assignment = await resolveAssignment(cid, payload.teamId ?? null, payload.assigneeIds); + + const id = ulid(); + const now = nowTimestamp(); + const doc: TaskDoc = { + companyId: cid, + projectId: payload.projectId, + projectName: project.name, + title: payload.title, + detail: payload.detail ?? null, + location: payload.location ?? null, + startDate: payload.startDate, + endDate, + status: "PLANNED", + priority: payload.priority, + teamId: payload.teamId ?? null, + teamName: assignment.teamName, + assigneeIds: assignment.ids, + assigneeNames: assignment.names, + statusNote: null, + completedAt: null, + createdBy: actorId, + createdAt: now, + updatedAt: now, + }; + await tenant(cid, "tasks").doc(id).create(doc); + await audit(cid, { + actorId, + actorRole: actorRoles.join(","), + action: "tasks.create", + resourceType: "tasks", + resourceId: id, + after: { title: doc.title, assignees: doc.assigneeIds, startDate: doc.startDate }, + }); + return taskToDto(id, doc); +} + +export async function updateTask( + cid: string, + id: string, + payload: TaskUpdate, + actorId: string, + actorRoles: string[], +): Promise> { + const ref = tenant(cid, "tasks").doc(id); + const snap = await ref.get(); + if (!snap.exists) throw ApiError.notFound("Task not found"); + const before = snap.data() as TaskDoc; + + const startDate = payload.startDate ?? before.startDate; + const endDate = payload.endDate ?? (payload.startDate ? payload.startDate : before.endDate); + assertSpan(startDate, endDate); + + let projectId = before.projectId; + let projectName = before.projectName; + if (payload.projectId && payload.projectId !== before.projectId) { + const projectSnap = await tenant(cid, "projects").doc(payload.projectId).get(); + if (!projectSnap.exists) throw ApiError.notFound("Project not found"); + projectId = payload.projectId; + projectName = (projectSnap.data() as ProjectDoc).name; + } + + // Re-resolve only when the assignment was actually part of this edit, so + // renaming a task does not silently re-expand a team that has since changed. + const reassigning = payload.teamId !== undefined || payload.assigneeIds !== undefined; + const assignment = reassigning + ? await resolveAssignment( + cid, + payload.teamId !== undefined ? payload.teamId : before.teamId, + payload.assigneeIds ?? [], + ) + : null; + + const status = payload.status ?? before.status; + const doc: TaskDoc = { + ...before, + projectId, + projectName, + title: payload.title ?? before.title, + detail: payload.detail !== undefined ? (payload.detail ?? null) : before.detail, + location: payload.location !== undefined ? (payload.location ?? null) : before.location, + startDate, + endDate, + status, + priority: payload.priority ?? before.priority, + teamId: assignment ? (payload.teamId ?? before.teamId ?? null) : before.teamId, + teamName: assignment ? assignment.teamName : before.teamName, + assigneeIds: assignment ? assignment.ids : before.assigneeIds, + assigneeNames: assignment ? assignment.names : before.assigneeNames, + completedAt: status === "DONE" ? (before.completedAt ?? nowTimestamp()) : null, + updatedAt: nowTimestamp(), + }; + await ref.set(doc); + await audit(cid, { + actorId, + actorRole: actorRoles.join(","), + action: "tasks.update", + resourceType: "tasks", + resourceId: id, + before: { title: before.title, status: before.status, assignees: before.assigneeIds }, + after: { title: doc.title, status: doc.status, assignees: doc.assigneeIds }, + }); + return taskToDto(id, doc); +} + +export async function deleteTask( + cid: string, + id: string, + actorId: string, + actorRoles: string[], +): Promise { + await tenant(cid, "tasks").doc(id).delete(); + await audit(cid, { + actorId, + actorRole: actorRoles.join(","), + action: "tasks.delete", + resourceType: "tasks", + resourceId: id, + }); +} + +/** + * Move a task's status. + * + * This is the one write an ordinary employee makes here, and it is deliberately + * the only one: he says how his own work is going, and cannot re-title it, + * re-date it, or hand it to somebody else. + */ +export async function setTaskStatus( + cid: string, + id: string, + status: TaskStatus, + note: string | null, + actorId: string, + actorRoles: string[], + hasWorkWrite: boolean, +): Promise> { + const ref = tenant(cid, "tasks").doc(id); + const snap = await ref.get(); + if (!snap.exists) throw ApiError.notFound("Task not found"); + const before = snap.data() as TaskDoc; + + if (!canSetStatus(before, actorId, hasWorkWrite)) { + throw ApiError.permissionDenied("This task is not assigned to you"); + } + + const doc: TaskDoc = { + ...before, + status, + statusNote: note ?? before.statusNote ?? null, + completedAt: status === "DONE" ? (before.completedAt ?? nowTimestamp()) : null, + updatedAt: nowTimestamp(), + }; + await ref.set(doc); + await audit(cid, { + actorId, + actorRole: actorRoles.join(","), + action: "tasks.status", + resourceType: "tasks", + resourceId: id, + before: { status: before.status }, + after: { status }, + }); + return taskToDto(id, doc); +} + +// --------------------------------------------------------------------- reads + +/** + * Every task overlapping [fromIso, toIso]. + * + * Firestore allows a range filter on one field only, so the query bounds the + * far end (endDate >= from) and the near end is filtered here. Ordering by + * endDate ascending puts the work finishing soonest first, which is also the + * order a planner wants to read. + */ +export async function listTasks( + cid: string, + fromIso: string, + toIso: string, + filter: { projectId?: string; employeeId?: string } = {}, +): Promise[]> { + let query = tenant(cid, "tasks").where("endDate", ">=", fromIso); + if (filter.projectId) query = query.where("projectId", "==", filter.projectId); + if (filter.employeeId) query = query.where("assigneeIds", "array-contains", filter.employeeId); + + const snap = await query.orderBy("endDate", "asc").limit(1000).get(); + return snap.docs + .map((doc) => ({ id: doc.id, doc: doc.data() as TaskDoc })) + .filter(({ doc }) => doc.startDate <= toIso) + .map(({ id, doc }) => taskToDto(id, doc)) + .sort( + (a, b) => + String(a.startDate).localeCompare(String(b.startDate)) || + String(a.title).localeCompare(String(b.title)), + ); +} + +export interface WorkDay { + date: string; + /** WORKING / WEEKEND / HOLIDAY, so the app can say why a day is empty. */ + kind: string; + tasks: Record[]; +} + +/** + * What one person is on today and on their next working day. + * + * This is the employee-facing answer, and the shape is the answer to the + * question rather than a table dump: two named days, each carrying why it is + * empty when it is empty. + */ +export async function myWork( + cid: string, + employeeId: string, + todayIso: string, +): Promise<{ today: WorkDay; next: WorkDay | null }> { + const settings = await getSettings(cid); + const weekendDays = settings.policies.weekendDays; + + // Two weeks is the horizon nextWorkingDay searches; ask the calendar once for + // the same window rather than twice. + const horizon = addDays(todayIso, 15); + const holidays = await holidaySet(cid, todayIso, horizon); + + const nextDate = nextWorkingDay(todayIso, weekendDays, holidays); + const tasks = await listTasks(cid, todayIso, nextDate ?? todayIso, { employeeId }); + + const dayOf = (date: string): WorkDay => ({ + date, + kind: classifyDay(date, weekendDays, holidays), + tasks: tasks.filter((t) => + taskRunsOn(t as unknown as TaskSpan, date), + ), + }); + + return { today: dayOf(todayIso), next: nextDate ? dayOf(nextDate) : null }; +} + +/** + * One day, by person — the planner's view of the same data. + * + * Everyone with work assigned that day, and what it is. An employee who appears + * with nothing is not listed: this answers "who is on what", and the roster + * already answers "who is in". + */ +export async function dayBoard( + cid: string, + dateIso: string, +): Promise<{ date: string; rows: { employeeId: string; name: string; tasks: Record[] }[] }> { + const tasks = await listTasks(cid, dateIso, dateIso); + + const byEmployee = new Map[] }>(); + for (const task of tasks) { + const ids = task.assigneeIds as string[]; + const names = task.assigneeNames as string[]; + ids.forEach((id, i) => { + const row = byEmployee.get(id) ?? { name: names[i] ?? id, tasks: [] }; + row.tasks.push(task); + byEmployee.set(id, row); + }); + } + + return { + date: dateIso, + rows: [...byEmployee.entries()] + .map(([employeeId, row]) => ({ employeeId, ...row })) + .sort((a, b) => a.name.localeCompare(b.name)), + }; +} diff --git a/backend/functions/tsconfig.json b/backend/functions/tsconfig.json new file mode 100644 index 0000000..b60baa0 --- /dev/null +++ b/backend/functions/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022"], + "moduleResolution": "node", + "outDir": "lib", + "rootDir": "src", + "strict": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/backend/functions/vitest.config.ts b/backend/functions/vitest.config.ts new file mode 100644 index 0000000..2c2aa7d --- /dev/null +++ b/backend/functions/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + /* + * Test files run one at a time. + * + * Not a preference — a correctness requirement. The integration suites all + * talk to ONE Firestore emulator database, and several of them ask + * questions about global state rather than about their own tenant: the + * vendor console lists every company there is, the device guard counts + * seats, support checks that a company has only one CRM account. Run two + * of those at once and each sees the other's fixtures. + * + * It failed the way concurrency does. Three consecutive runs of the full + * suite against a freshly wiped emulator gave 2 failures, then 6, then + * none, in three different files, with messages pointing nowhere near the + * cause. Half an hour went into chasing three suites that were innocent. + * The same three runs with file parallelism off: 349, 349, 349. + * + * The alternative — a separate emulator project per file — is the better + * engineering answer and costs more than it is worth at this size. The + * suite takes seconds either way, and a test run nobody believes is worth + * less than a slow one. + */ + fileParallelism: false, + }, +}); diff --git a/backend/monitoring/attendance-integrity-alert.json b/backend/monitoring/attendance-integrity-alert.json new file mode 100644 index 0000000..726dc9c --- /dev/null +++ b/backend/monitoring/attendance-integrity-alert.json @@ -0,0 +1,23 @@ +{ + "displayName": "Attendance integrity — recorded punches missing from the board", + "documentation": { + "content": "The nightly audit (attendanceIntegrityAudit) found employees with punches for a day whose attendanceDays projection is missing or older than those punches.\n\nThis is the failure that went unnoticed for weeks: punches stored, projection never written, staff showing as absent.\n\nWhat to do:\n1. Read the finding: firebase functions:log --only attendanceIntegrityAudit --project worktrack-prod\n2. Look for the underlying error in the api function around the punch times — a FAILED_PRECONDITION means a Firestore index is missing again.\n3. Repair the affected days once the cause is fixed:\n cd backend/functions && npm run build\n GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/backfill-attendance.js # dry run\n GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/backfill-attendance.js --apply", + "mimeType": "text/markdown" + }, + "conditions": [ + { + "displayName": "ATTENDANCE_INTEGRITY logged at error level", + "conditionMatchedLog": { + "filter": "resource.type=\"cloud_run_revision\" AND resource.labels.service_name=\"attendanceintegrityaudit\" AND severity>=ERROR AND (textPayload:\"ATTENDANCE_INTEGRITY\" OR jsonPayload.message:\"ATTENDANCE_INTEGRITY\")" + } + } + ], + "combiner": "OR", + "alertStrategy": { + "notificationRateLimit": { + "period": "3600s" + }, + "autoClose": "604800s" + }, + "enabled": true +} diff --git a/backend/monitoring/setup-alerts.sh b/backend/monitoring/setup-alerts.sh new file mode 100755 index 0000000..70d8e5d --- /dev/null +++ b/backend/monitoring/setup-alerts.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# +# Creates the alerting that turns the nightly integrity audit into something +# that actually reaches a person. +# +# The audit already logs and stores its findings, but a log nobody reads is +# how the original failure stayed invisible for weeks. This attaches an email +# alert to it. +# +# Usage: +# bash backend/monitoring/setup-alerts.sh you@example.com [project-id] +# +# Requires gcloud, logged in with an account that can edit monitoring: +# gcloud auth login +# +# Safe to re-run: an existing channel for the same address is reused, and the +# policy is matched by display name and updated rather than duplicated. +set -euo pipefail + +EMAIL="${1:-}" +PROJECT="${2:-worktrack-prod}" +POLICY_FILE="$(cd "$(dirname "$0")" && pwd)/attendance-integrity-alert.json" +POLICY_NAME="Attendance integrity — recorded punches missing from the board" + +if [ -z "$EMAIL" ]; then + echo "usage: bash backend/monitoring/setup-alerts.sh [project-id]" >&2 + exit 1 +fi + +command -v gcloud >/dev/null || { + echo "✗ gcloud not on PATH. Try:" >&2 + echo ' export PATH=/opt/homebrew/share/google-cloud-sdk/bin:"$PATH"' >&2 + exit 1 +} + +gcloud auth list --filter=status:ACTIVE --format="value(account)" | grep -q . || { + echo "✗ gcloud is not logged in. Run: gcloud auth login" >&2 + exit 1 +} + +echo "==> Project: $PROJECT" +echo "==> Alert recipient: $EMAIL" + +# --- Notification channel ----------------------------------------------- +# Google emails the address a verification link; the alert only delivers once +# that link is clicked. +CHANNEL=$(gcloud alpha monitoring channels list \ + --project="$PROJECT" \ + --filter="type=email AND labels.email_address=$EMAIL" \ + --format="value(name)" | head -1) + +if [ -n "$CHANNEL" ]; then + echo "==> Reusing notification channel: $CHANNEL" +else + echo "==> Creating email notification channel…" + CHANNEL=$(gcloud alpha monitoring channels create \ + --project="$PROJECT" \ + --display-name="WorkTrack alerts" \ + --type=email \ + --channel-labels="email_address=$EMAIL" \ + --format="value(name)") + echo " $CHANNEL" + echo " Check $EMAIL for a verification link — alerts stay undelivered until it is clicked." +fi + +# --- Alert policy -------------------------------------------------------- +EXISTING=$(gcloud alpha monitoring policies list \ + --project="$PROJECT" \ + --filter="displayName='$POLICY_NAME'" \ + --format="value(name)" | head -1) + +if [ -n "$EXISTING" ]; then + echo "==> Updating existing policy: $EXISTING" + gcloud alpha monitoring policies update "$EXISTING" \ + --project="$PROJECT" \ + --policy-from-file="$POLICY_FILE" \ + --set-notification-channels="$CHANNEL" >/dev/null +else + echo "==> Creating alert policy…" + gcloud alpha monitoring policies create \ + --project="$PROJECT" \ + --policy-from-file="$POLICY_FILE" \ + --notification-channels="$CHANNEL" >/dev/null +fi + +echo +echo "✔ Done. The nightly audit now emails $EMAIL when attendance goes missing." +echo " Verify: gcloud alpha monitoring policies list --project=$PROJECT --format='value(displayName,enabled)'" diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts new file mode 100644 index 0000000..0689594 --- /dev/null +++ b/build-logic/convention/build.gradle.kts @@ -0,0 +1,61 @@ +plugins { + `kotlin-dsl` +} + +group = "app.worktrack.buildlogic" + +// Compatibility flags instead of a strict toolchain: any JDK 17+ (including the +// IDE's bundled JBR) can build this, producing Java 17 bytecode. Uses .set() +// (not the `=` assignment) because this file compiles under the embedded +// kotlin-dsl compiler, where property-assignment operators aren't available. +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + compileOnly(libs.android.gradle.plugin) + compileOnly(libs.kotlin.gradle.plugin) + compileOnly(libs.ksp.gradle.plugin) + compileOnly(libs.compose.compiler.gradle.plugin) + compileOnly(libs.room.gradle.plugin) +} + +gradlePlugin { + plugins { + register("androidApplication") { + id = "worktrack.android.application" + implementationClass = "AndroidApplicationConventionPlugin" + } + register("androidLibrary") { + id = "worktrack.android.library" + implementationClass = "AndroidLibraryConventionPlugin" + } + register("androidLibraryCompose") { + id = "worktrack.android.library.compose" + implementationClass = "AndroidLibraryComposeConventionPlugin" + } + register("androidFeature") { + id = "worktrack.android.feature" + implementationClass = "AndroidFeatureConventionPlugin" + } + register("androidHilt") { + id = "worktrack.android.hilt" + implementationClass = "AndroidHiltConventionPlugin" + } + register("androidRoom") { + id = "worktrack.android.room" + implementationClass = "AndroidRoomConventionPlugin" + } + register("jvmLibrary") { + id = "worktrack.jvm.library" + implementationClass = "JvmLibraryConventionPlugin" + } + } +} diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt new file mode 100644 index 0000000..c95b61d --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -0,0 +1,47 @@ +import app.worktrack.buildlogic.configureAndroidCompose +import app.worktrack.buildlogic.configureKotlinAndroid +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure + +class AndroidApplicationConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.android.application") + pluginManager.apply("org.jetbrains.kotlin.android") + + extensions.configure { + configureKotlinAndroid(this) + configureAndroidCompose(this) + + defaultConfig { + // Google Play's floor for new apps and updates since + // 31 August 2026. Targeting 36 opts the app into Android + // 16's behaviour changes; the one that would have bitten + // us is enforced edge-to-edge, and MainActivity already + // calls enableEdgeToEdge() with a Scaffold that consumes + // the insets, so there is nothing to adapt. + targetSdk = 36 + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt new file mode 100644 index 0000000..c4b0e68 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -0,0 +1,34 @@ +import app.worktrack.buildlogic.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +/** + * Standard setup for feature modules: Compose library + Hilt + the dependency set + * every screen needs (domain contracts, design system, lifecycle, navigation). + */ +class AndroidFeatureConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("worktrack.android.library.compose") + pluginManager.apply("worktrack.android.hilt") + + dependencies { + "implementation"(project(":core:common")) + "implementation"(project(":core:model")) + "implementation"(project(":core:domain")) + "implementation"(project(":core:designsystem")) + + "implementation"(libs.findLibrary("androidx-lifecycle-runtime-compose").get()) + "implementation"(libs.findLibrary("androidx-lifecycle-viewmodel-compose").get()) + "implementation"(libs.findLibrary("androidx-navigation-compose").get()) + "implementation"(libs.findLibrary("hilt-navigation-compose").get()) + "implementation"(libs.findLibrary("kotlinx-coroutines-android").get()) + "implementation"(libs.findLibrary("androidx-compose-material-icons").get()) + + "testImplementation"(libs.findLibrary("turbine").get()) + "testImplementation"(libs.findLibrary("mockk").get()) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt new file mode 100644 index 0000000..b6d9d95 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt @@ -0,0 +1,18 @@ +import app.worktrack.buildlogic.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +class AndroidHiltConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.google.devtools.ksp") + pluginManager.apply("com.google.dagger.hilt.android") + + dependencies { + "implementation"(libs.findLibrary("hilt-android").get()) + "ksp"(libs.findLibrary("hilt-compiler").get()) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt new file mode 100644 index 0000000..24bb9b6 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt @@ -0,0 +1,16 @@ +import app.worktrack.buildlogic.configureAndroidCompose +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure + +class AndroidLibraryComposeConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("worktrack.android.library") + extensions.configure { + configureAndroidCompose(this) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt new file mode 100644 index 0000000..7731063 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -0,0 +1,30 @@ +import app.worktrack.buildlogic.configureKotlinAndroid +import app.worktrack.buildlogic.libs +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +class AndroidLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.android.library") + pluginManager.apply("org.jetbrains.kotlin.android") + + extensions.configure { + configureKotlinAndroid(this) + + defaultConfig { + consumerProguardFiles("consumer-rules.pro") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + + dependencies { + "testImplementation"(libs.findLibrary("junit4").get()) + "testImplementation"(libs.findLibrary("kotlinx-coroutines-test").get()) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt new file mode 100644 index 0000000..8c496ec --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt @@ -0,0 +1,27 @@ +import app.worktrack.buildlogic.libs +import androidx.room.gradle.RoomExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +class AndroidRoomConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("androidx.room") + pluginManager.apply("com.google.devtools.ksp") + + // Exported schemas are the migration contract; they are version-controlled. + extensions.configure { + schemaDirectory("$projectDir/schemas") + } + + dependencies { + "implementation"(libs.findLibrary("room-runtime").get()) + "implementation"(libs.findLibrary("room-ktx").get()) + "ksp"(libs.findLibrary("room-compiler").get()) + "testImplementation"(libs.findLibrary("room-testing").get()) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt new file mode 100644 index 0000000..b3ad5b2 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt @@ -0,0 +1,42 @@ +import app.worktrack.buildlogic.libs +import org.gradle.api.JavaVersion +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.kotlin.dsl.assign +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension + +/** + * Pure-JVM Kotlin module: fastest to compile and enforces that domain logic + * stays free of Android framework types. + * + * Java 17 via compatibility flags (not a strict toolchain) so any JDK 17+ — + * including Android Studio's bundled runtime — builds without needing a + * separately installed or downloaded toolchain. + */ +class JvmLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("org.jetbrains.kotlin.jvm") + + extensions.configure { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + extensions.configure { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } + } + + dependencies { + "testImplementation"(libs.findLibrary("junit4").get()) + "testImplementation"(libs.findLibrary("kotlinx-coroutines-test").get()) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/AndroidCompose.kt new file mode 100644 index 0000000..7c75411 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/AndroidCompose.kt @@ -0,0 +1,27 @@ +package app.worktrack.buildlogic + +import com.android.build.api.dsl.CommonExtension +import org.gradle.api.Project +import org.gradle.kotlin.dsl.dependencies + +/** Enables Jetpack Compose with the shared BOM and tooling wiring. */ +internal fun Project.configureAndroidCompose(commonExtension: CommonExtension<*, *, *, *, *, *>) { + pluginManager.apply("org.jetbrains.kotlin.plugin.compose") + + commonExtension.apply { + buildFeatures { + compose = true + } + } + + dependencies { + val bom = libs.findLibrary("androidx-compose-bom").get() + "implementation"(platform(bom)) + "androidTestImplementation"(platform(bom)) + "implementation"(libs.findLibrary("androidx-compose-ui").get()) + "implementation"(libs.findLibrary("androidx-compose-ui-graphics").get()) + "implementation"(libs.findLibrary("androidx-compose-material3").get()) + "implementation"(libs.findLibrary("androidx-compose-ui-tooling-preview").get()) + "debugImplementation"(libs.findLibrary("androidx-compose-ui-tooling").get()) + } +} diff --git a/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/KotlinAndroid.kt new file mode 100644 index 0000000..02e0ba5 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/KotlinAndroid.kt @@ -0,0 +1,44 @@ +package app.worktrack.buildlogic + +import com.android.build.api.dsl.CommonExtension +import org.gradle.api.JavaVersion +import org.gradle.api.Project +import org.gradle.kotlin.dsl.assign +import org.gradle.kotlin.dsl.configure +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension + +/** + * Baseline Android + Kotlin configuration shared by every Android module. + * + * minSdk 26 gives us java.time and modern security APIs without desugaring; + * WorkTrack targets managed corporate devices where API 26+ coverage is near-total. + */ +internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension<*, *, *, *, *, *>) { + commonExtension.apply { + // 36 because Google Play has required it of new apps and updates since + // 31 August 2026, and we cannot list on Play below it. It also has to + // be at least targetSdk, which is set to the same in the application + // convention. + compileSdk = 36 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + } + + extensions.configure { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + freeCompilerArgs.addAll( + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=kotlinx.coroutines.FlowPreview", + ) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/ProjectExtensions.kt b/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/ProjectExtensions.kt new file mode 100644 index 0000000..9dc3caa --- /dev/null +++ b/build-logic/convention/src/main/kotlin/app/worktrack/buildlogic/ProjectExtensions.kt @@ -0,0 +1,10 @@ +package app.worktrack.buildlogic + +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalog +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.getByType + +/** Typed access to the shared version catalog from within convention plugins. */ +val Project.libs: VersionCatalog + get() = extensions.getByType().named("libs") diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000..875164f --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,15 @@ +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" +include(":convention") diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..807aeff --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,14 @@ +// Root build file: plugin versions are resolved here once so that all modules +// share a single, consistent toolchain. Convention plugins in build-logic apply them. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.room) apply false + alias(libs.plugins.google.services) apply false +} diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts new file mode 100644 index 0000000..cd553d3 --- /dev/null +++ b/core/common/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.worktrack.jvm.library) +} + +dependencies { + api(libs.kotlinx.coroutines.core) + implementation(libs.javax.inject) +} diff --git a/core/common/src/main/kotlin/app/worktrack/core/common/coroutines/DispatcherProvider.kt b/core/common/src/main/kotlin/app/worktrack/core/common/coroutines/DispatcherProvider.kt new file mode 100644 index 0000000..d4aa8a8 --- /dev/null +++ b/core/common/src/main/kotlin/app/worktrack/core/common/coroutines/DispatcherProvider.kt @@ -0,0 +1,24 @@ +package app.worktrack.core.common.coroutines + +import javax.inject.Inject +import javax.inject.Qualifier +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +/** Injectable dispatchers so coroutine context is swappable in tests. */ +interface DispatcherProvider { + val io: CoroutineDispatcher + val default: CoroutineDispatcher + val main: CoroutineDispatcher +} + +class DefaultDispatcherProvider @Inject constructor() : DispatcherProvider { + override val io: CoroutineDispatcher = Dispatchers.IO + override val default: CoroutineDispatcher = Dispatchers.Default + override val main: CoroutineDispatcher = Dispatchers.Main +} + +/** Application-lifetime CoroutineScope (SupervisorJob + Default), provided by the app module. */ +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class ApplicationScope diff --git a/core/common/src/main/kotlin/app/worktrack/core/common/geo/GeoDistance.kt b/core/common/src/main/kotlin/app/worktrack/core/common/geo/GeoDistance.kt new file mode 100644 index 0000000..cb81dcd --- /dev/null +++ b/core/common/src/main/kotlin/app/worktrack/core/common/geo/GeoDistance.kt @@ -0,0 +1,22 @@ +package app.worktrack.core.common.geo + +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** Great-circle distance (haversine). Accurate to well under geofence tolerances. */ +object GeoDistance { + + private const val EARTH_RADIUS_METERS = 6_371_000.0 + + fun meters(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double { + val dLat = Math.toRadians(lat2 - lat1) + val dLng = Math.toRadians(lng2 - lng1) + val a = sin(dLat / 2) * sin(dLat / 2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * + sin(dLng / 2) * sin(dLng / 2) + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + return EARTH_RADIUS_METERS * c + } +} diff --git a/core/common/src/main/kotlin/app/worktrack/core/common/id/Ulid.kt b/core/common/src/main/kotlin/app/worktrack/core/common/id/Ulid.kt new file mode 100644 index 0000000..3323b2d --- /dev/null +++ b/core/common/src/main/kotlin/app/worktrack/core/common/id/Ulid.kt @@ -0,0 +1,48 @@ +package app.worktrack.core.common.id + +import java.security.SecureRandom + +/** + * ULID generator (26-char Crockford base32: 48-bit timestamp + 80-bit randomness). + * + * ULIDs are the platform-wide ID scheme because they are generatable offline + * (no server round-trip), lexicographically sortable by creation time (index + * friendly in both Room and Firestore), and collision-safe across devices. + */ +object Ulid { + + private const val ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + private const val TIME_CHARS = 10 + private const val RANDOM_BYTES = 10 // 80 bits -> 16 base32 chars + + private val random = SecureRandom() + + fun generate(timestampMillis: Long = System.currentTimeMillis()): String { + require(timestampMillis >= 0) { "timestamp must be non-negative" } + val chars = CharArray(26) + + var ts = timestampMillis + for (i in TIME_CHARS - 1 downTo 0) { + chars[i] = ENCODING[(ts and 0x1F).toInt()] + ts = ts ushr 5 + } + + val rnd = ByteArray(RANDOM_BYTES) + random.nextBytes(rnd) + var buffer = 0L + var bitsInBuffer = 0 + var out = TIME_CHARS + for (b in rnd) { + buffer = (buffer shl 8) or (b.toLong() and 0xFF) + bitsInBuffer += 8 + while (bitsInBuffer >= 5) { + bitsInBuffer -= 5 + chars[out++] = ENCODING[((buffer ushr bitsInBuffer) and 0x1F).toInt()] + } + } + return String(chars) + } + + fun isValid(value: String): Boolean = + value.length == 26 && value.all { ENCODING.indexOf(it.uppercaseChar()) >= 0 } +} diff --git a/core/common/src/main/kotlin/app/worktrack/core/common/result/AppError.kt b/core/common/src/main/kotlin/app/worktrack/core/common/result/AppError.kt new file mode 100644 index 0000000..7dbfac0 --- /dev/null +++ b/core/common/src/main/kotlin/app/worktrack/core/common/result/AppError.kt @@ -0,0 +1,46 @@ +package app.worktrack.core.common.result + +/** + * Canonical error taxonomy for the whole app. Layers map their native failures + * (IOException, HTTP problem+json, Firestore errors) into one of these so that + * UI and domain logic never depend on transport-specific exception types. + */ +sealed interface AppError { + + /** No connectivity, DNS failure, timeout — safe to retry when back online. */ + data object Network : AppError + + /** Missing/expired credentials; the session must be re-established. */ + data object Unauthenticated : AppError + + /** Authenticated but not allowed (RBAC denial, tenant mismatch). */ + data object PermissionDenied : AppError + + data object NotFound : AppError + + /** Client-side or server-side input validation failure. */ + data class Validation( + val message: String, + val fieldErrors: Map = emptyMap(), + ) : AppError + + /** + * A domain rule rejected the operation (e.g. GEOFENCE_VIOLATION, + * INSUFFICIENT_LEAVE_BALANCE). [code] matches the API error catalog. + */ + data class Business(val code: String, val message: String) : AppError + + /** Non-2xx HTTP response that does not map to a more specific error. */ + data class Http(val status: Int, val code: String? = null, val message: String? = null) : AppError + + /** Programming errors and anything unforeseen; always logged, never swallowed. */ + data class Unexpected(val cause: Throwable? = null) : AppError +} + +/** True when retrying the same operation later can plausibly succeed. */ +val AppError.isRetryable: Boolean + get() = when (this) { + AppError.Network -> true + is AppError.Http -> status in 500..599 || status == 429 + else -> false + } diff --git a/core/common/src/main/kotlin/app/worktrack/core/common/result/AppResult.kt b/core/common/src/main/kotlin/app/worktrack/core/common/result/AppResult.kt new file mode 100644 index 0000000..85a0b3d --- /dev/null +++ b/core/common/src/main/kotlin/app/worktrack/core/common/result/AppResult.kt @@ -0,0 +1,46 @@ +package app.worktrack.core.common.result + +/** + * Explicit success/failure channel for every fallible operation. + * Exceptions never cross layer boundaries; they are converted at the edge. + */ +sealed interface AppResult { + data class Success(val data: T) : AppResult + data class Failure(val error: AppError) : AppResult + + companion object { + fun success(data: T): AppResult = Success(data) + fun failure(error: AppError): AppResult = Failure(error) + } +} + +inline fun AppResult.map(transform: (T) -> R): AppResult = when (this) { + is AppResult.Success -> AppResult.Success(transform(data)) + is AppResult.Failure -> this +} + +inline fun AppResult.flatMap(transform: (T) -> AppResult): AppResult = when (this) { + is AppResult.Success -> transform(data) + is AppResult.Failure -> this +} + +inline fun AppResult.onSuccess(action: (T) -> Unit): AppResult { + if (this is AppResult.Success) action(data) + return this +} + +inline fun AppResult.onFailure(action: (AppError) -> Unit): AppResult { + if (this is AppResult.Failure) action(error) + return this +} + +inline fun AppResult.fold(onSuccess: (T) -> R, onFailure: (AppError) -> R): R = when (this) { + is AppResult.Success -> onSuccess(data) + is AppResult.Failure -> onFailure(error) +} + +fun AppResult.getOrNull(): T? = (this as? AppResult.Success)?.data + +fun AppResult.errorOrNull(): AppError? = (this as? AppResult.Failure)?.error + +val AppResult<*>.isSuccess: Boolean get() = this is AppResult.Success diff --git a/core/common/src/main/kotlin/app/worktrack/core/common/time/SolarHijri.kt b/core/common/src/main/kotlin/app/worktrack/core/common/time/SolarHijri.kt new file mode 100644 index 0000000..9fd09d0 --- /dev/null +++ b/core/common/src/main/kotlin/app/worktrack/core/common/time/SolarHijri.kt @@ -0,0 +1,128 @@ +package app.worktrack.core.common.time + +import java.time.LocalDate + +/** + * Solar Hijri (هجری شمسی) date — the official calendar of Afghanistan. + * Month 1 is Hamal/حمل (vernal equinox, ~21 March). + */ +data class SolarHijriDate(val year: Int, val month: Int, val day: Int) { + init { + require(month in 1..12) { "month must be 1..12" } + require(day in 1..31) { "day must be 1..31" } + } + + fun toGregorian(): LocalDate = SolarHijri.toGregorian(this) + + /** Sortable "1405-04" style key for one month; used for paging state. */ + fun monthKey(): String = "%04d-%02d".format(year, month) +} + +/** + * Solar Hijri <-> Gregorian conversion using the arithmetic astronomical-cycle + * algorithm from jalaali-js (Behrooz/Birashk break years), accurate for the + * years this platform will ever process (1178–1633 AP / 1799–2254 AD). + * Afghanistan shares the leap-year structure; only month names differ. + */ +object SolarHijri { + + private val BREAKS = intArrayOf( + -61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, + 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178, + ) + + fun fromGregorian(date: LocalDate): SolarHijriDate = d2j(g2d(date.year, date.monthValue, date.dayOfMonth)) + + fun toGregorian(date: SolarHijriDate): LocalDate = d2g(j2d(date.year, date.month, date.day)) + + fun today(timeProvider: TimeProvider): SolarHijriDate = fromGregorian(timeProvider.today()) + + fun isLeapYear(year: Int): Boolean = jalCal(year).leap == 0 + + fun monthLength(year: Int, month: Int): Int = when { + month <= 6 -> 31 + month <= 11 -> 30 + else -> if (isLeapYear(year)) 30 else 29 + } + + /** First Gregorian day of a Solar Hijri month (for date-range queries). */ + fun monthStart(year: Int, month: Int): LocalDate = toGregorian(SolarHijriDate(year, month, 1)) + + fun monthEnd(year: Int, month: Int): LocalDate = + toGregorian(SolarHijriDate(year, month, monthLength(year, month))) + + // ------------------------------------------------------------ internals + + private data class JalCal(val leap: Int, val gy: Int, val march: Int) + + private fun jalCal(jy: Int): JalCal { + require(jy in (BREAKS.first() + 1) until BREAKS.last()) { "year $jy out of supported range" } + val gy = jy + 621 + var leapJ = -14 + var jp = BREAKS[0] + + var jump = 0 + for (i in 1 until BREAKS.size) { + val jm = BREAKS[i] + jump = jm - jp + if (jy < jm) break + leapJ += jump / 33 * 8 + jump % 33 / 4 + jp = jm + } + var n = jy - jp + + leapJ += n / 33 * 8 + (n % 33 + 3) / 4 + if (jump % 33 == 4 && jump - n == 4) leapJ += 1 + + val leapG = gy / 4 - (gy / 100 + 1) * 3 / 4 - 150 + val march = 20 + leapJ - leapG + + if (jump - n < 6) n = n - jump + (jump + 4) / 33 * 33 + var leap = ((n + 1) % 33 - 1) % 4 + if (leap == -1) leap = 4 + + return JalCal(leap = leap, gy = gy, march = march) + } + + private fun g2d(gy: Int, gm: Int, gd: Int): Int { + var d = (gy + (gm - 8) / 6 + 100100) * 1461 / 4 + + (153 * ((gm + 9) % 12) + 2) / 5 + gd - 34840408 + d = d - (gy + 100100 + (gm - 8) / 6) / 100 * 3 / 4 + 752 + return d + } + + private fun d2g(jdn: Int): LocalDate { + var j = 4 * jdn + 139361631 + j += (4 * jdn + 183187720) / 146097 * 3 / 4 * 4 - 3908 + val i = j % 1461 / 4 * 5 + 308 + val gd = i % 153 / 5 + 1 + val gm = i / 153 % 12 + 1 + val gy = j / 1461 - 100100 + (8 - gm) / 6 + return LocalDate.of(gy, gm, gd) + } + + private fun j2d(jy: Int, jm: Int, jd: Int): Int { + val r = jalCal(jy) + return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - jm / 7 * (jm - 7) + jd - 1 + } + + private fun d2j(jdn: Int): SolarHijriDate { + val gy = d2g(jdn).year + var jy = gy - 621 + val r = jalCal(jy) + val jdn1f = g2d(gy, 3, r.march) + var k = jdn - jdn1f + + if (k >= 0) { + if (k <= 185) { + return SolarHijriDate(jy, 1 + k / 31, k % 31 + 1) + } + k -= 186 + } else { + jy -= 1 + k += 179 + if (r.leap == 1) k += 1 + } + return SolarHijriDate(jy, 7 + k / 30, k % 30 + 1) + } +} diff --git a/core/common/src/main/kotlin/app/worktrack/core/common/time/TimeProvider.kt b/core/common/src/main/kotlin/app/worktrack/core/common/time/TimeProvider.kt new file mode 100644 index 0000000..29a5e0d --- /dev/null +++ b/core/common/src/main/kotlin/app/worktrack/core/common/time/TimeProvider.kt @@ -0,0 +1,21 @@ +package app.worktrack.core.common.time + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import javax.inject.Inject + +/** + * Injectable clock. Production code never calls Instant.now() directly so that + * time-dependent logic (attendance windows, accruals) is deterministic in tests. + */ +interface TimeProvider { + fun now(): Instant + fun zone(): ZoneId + fun today(): LocalDate = LocalDate.ofInstant(now(), zone()) +} + +class SystemTimeProvider @Inject constructor() : TimeProvider { + override fun now(): Instant = Instant.now() + override fun zone(): ZoneId = ZoneId.systemDefault() +} diff --git a/core/common/src/test/kotlin/app/worktrack/core/common/id/UlidTest.kt b/core/common/src/test/kotlin/app/worktrack/core/common/id/UlidTest.kt new file mode 100644 index 0000000..7054791 --- /dev/null +++ b/core/common/src/test/kotlin/app/worktrack/core/common/id/UlidTest.kt @@ -0,0 +1,35 @@ +package app.worktrack.core.common.id + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class UlidTest { + + @Test + fun `generates 26 char crockford base32`() { + val ulid = Ulid.generate() + assertEquals(26, ulid.length) + assertTrue(Ulid.isValid(ulid)) + } + + @Test + fun `is lexicographically sortable by timestamp`() { + val earlier = Ulid.generate(timestampMillis = 1_000_000L) + val later = Ulid.generate(timestampMillis = 2_000_000L) + assertTrue(earlier < later) + } + + @Test + fun `encodes identical timestamps with identical prefix`() { + val a = Ulid.generate(timestampMillis = 1_700_000_000_000) + val b = Ulid.generate(timestampMillis = 1_700_000_000_000) + assertEquals(a.take(10), b.take(10)) + } + + @Test + fun `no collisions across a large batch`() { + val batch = (1..10_000).map { Ulid.generate() }.toSet() + assertEquals(10_000, batch.size) + } +} diff --git a/core/common/src/test/kotlin/app/worktrack/core/common/time/SolarHijriTest.kt b/core/common/src/test/kotlin/app/worktrack/core/common/time/SolarHijriTest.kt new file mode 100644 index 0000000..abe5cbb --- /dev/null +++ b/core/common/src/test/kotlin/app/worktrack/core/common/time/SolarHijriTest.kt @@ -0,0 +1,67 @@ +package app.worktrack.core.common.time + +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SolarHijriTest { + + @Test + fun `nawruz 1405 is 21 March 2026`() { + assertEquals( + SolarHijriDate(1405, 1, 1), + SolarHijri.fromGregorian(LocalDate.of(2026, 3, 21)), + ) + assertEquals( + LocalDate.of(2026, 3, 21), + SolarHijriDate(1405, 1, 1).toGregorian(), + ) + } + + @Test + fun `mid year conversion`() { + // 17 July 2026 = 26 Saratan 1405 + assertEquals( + SolarHijriDate(1405, 4, 26), + SolarHijri.fromGregorian(LocalDate.of(2026, 7, 17)), + ) + } + + @Test + fun `epoch day converts`() { + // 1 January 1970 = 11 Jadi 1348 + assertEquals( + SolarHijriDate(1348, 10, 11), + SolarHijri.fromGregorian(LocalDate.of(1970, 1, 1)), + ) + } + + @Test + fun `round trip across two full years`() { + var date = LocalDate.of(2025, 3, 1) + repeat(730) { + val shamsi = SolarHijri.fromGregorian(date) + assertEquals("round trip failed for $date", date, shamsi.toGregorian()) + date = date.plusDays(1) + } + } + + @Test + fun `leap years`() { + assertTrue(SolarHijri.isLeapYear(1403)) + assertFalse(SolarHijri.isLeapYear(1404)) + assertFalse(SolarHijri.isLeapYear(1405)) + assertEquals(30, SolarHijri.monthLength(1403, 12)) + assertEquals(29, SolarHijri.monthLength(1404, 12)) + assertEquals(31, SolarHijri.monthLength(1405, 6)) + assertEquals(30, SolarHijri.monthLength(1405, 7)) + } + + @Test + fun `month boundaries`() { + assertEquals(LocalDate.of(2026, 6, 22), SolarHijri.monthStart(1405, 4)) + assertEquals(LocalDate.of(2026, 7, 22), SolarHijri.monthEnd(1405, 4)) + } +} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts new file mode 100644 index 0000000..288dd96 --- /dev/null +++ b/core/data/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + alias(libs.plugins.worktrack.android.library) + alias(libs.plugins.worktrack.android.hilt) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "app.worktrack.core.data" +} + +dependencies { + api(projects.core.domain) + implementation(projects.core.common) + implementation(projects.core.model) + implementation(projects.core.database) + implementation(projects.core.datastore) + implementation(projects.core.network) + + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.play.services) + implementation(libs.kotlinx.serialization.json) + + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.auth) + + testImplementation(libs.turbine) +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/auth/FirebaseAuthTokenProvider.kt b/core/data/src/main/kotlin/app/worktrack/core/data/auth/FirebaseAuthTokenProvider.kt new file mode 100644 index 0000000..663a9cc --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/auth/FirebaseAuthTokenProvider.kt @@ -0,0 +1,25 @@ +package app.worktrack.core.data.auth + +import app.worktrack.core.network.auth.AuthTokenProvider +import com.google.firebase.auth.FirebaseAuth +import dagger.Lazy +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.tasks.await + +@Singleton +class FirebaseAuthTokenProvider @Inject constructor( + // Lazy so building the OkHttp/Retrofit graph never forces FirebaseAuth init. + private val firebaseAuth: Lazy, +) : AuthTokenProvider { + + override suspend fun idToken(forceRefresh: Boolean): String? = + try { + firebaseAuth.get().currentUser?.getIdToken(forceRefresh)?.await()?.token + } catch (_: Exception) { + // Offline, revoked, or Firebase not configured: callers treat null as + // "no credential"; the API responds 401 and the UI routes to + // re-authentication if needed. + null + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/device/StoredDeviceIdProvider.kt b/core/data/src/main/kotlin/app/worktrack/core/data/device/StoredDeviceIdProvider.kt new file mode 100644 index 0000000..ad0be6f --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/device/StoredDeviceIdProvider.kt @@ -0,0 +1,14 @@ +package app.worktrack.core.data.device + +import app.worktrack.core.datastore.DeviceIdStore +import app.worktrack.core.network.device.DeviceIdProvider +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StoredDeviceIdProvider @Inject constructor( + private val store: DeviceIdStore, +) : DeviceIdProvider { + + override suspend fun deviceId(): String = store.deviceId() +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/di/DataModule.kt b/core/data/src/main/kotlin/app/worktrack/core/data/di/DataModule.kt new file mode 100644 index 0000000..214ca0d --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/di/DataModule.kt @@ -0,0 +1,58 @@ +package app.worktrack.core.data.di + +import app.worktrack.core.common.coroutines.DefaultDispatcherProvider +import app.worktrack.core.common.coroutines.DispatcherProvider +import app.worktrack.core.common.time.SystemTimeProvider +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.data.auth.FirebaseAuthTokenProvider +import app.worktrack.core.data.repository.AnnouncementRepositoryImpl +import app.worktrack.core.data.repository.AttendanceRepositoryImpl +import app.worktrack.core.data.repository.AuthRepositoryImpl +import app.worktrack.core.data.repository.FaceRepositoryImpl +import app.worktrack.core.data.repository.LeaveRepositoryImpl +import app.worktrack.core.data.repository.PayslipRepositoryImpl +import app.worktrack.core.data.repository.SyncRepositoryImpl +import app.worktrack.core.data.repository.WorkRepositoryImpl +import app.worktrack.core.domain.repository.AnnouncementRepository +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.domain.repository.AuthRepository +import app.worktrack.core.domain.repository.FaceRepository +import app.worktrack.core.domain.repository.LeaveRepository +import app.worktrack.core.domain.repository.PayslipRepository +import app.worktrack.core.domain.repository.SyncRepository +import app.worktrack.core.domain.repository.WorkRepository +import app.worktrack.core.data.device.StoredDeviceIdProvider +import app.worktrack.core.network.auth.AuthTokenProvider +import app.worktrack.core.network.device.DeviceIdProvider +import com.google.firebase.auth.FirebaseAuth +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface DataModule { + + @Binds fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository + @Binds fun bindAttendanceRepository(impl: AttendanceRepositoryImpl): AttendanceRepository + @Binds fun bindLeaveRepository(impl: LeaveRepositoryImpl): LeaveRepository + @Binds fun bindFaceRepository(impl: FaceRepositoryImpl): FaceRepository + @Binds fun bindPayslipRepository(impl: PayslipRepositoryImpl): PayslipRepository + @Binds fun bindAnnouncementRepository(impl: AnnouncementRepositoryImpl): AnnouncementRepository + @Binds fun bindWorkRepository(impl: WorkRepositoryImpl): WorkRepository + @Binds fun bindSyncRepository(impl: SyncRepositoryImpl): SyncRepository + @Binds fun bindAuthTokenProvider(impl: FirebaseAuthTokenProvider): AuthTokenProvider + + @Binds fun bindDeviceIdProvider(impl: StoredDeviceIdProvider): DeviceIdProvider + @Binds fun bindTimeProvider(impl: SystemTimeProvider): TimeProvider + @Binds fun bindDispatcherProvider(impl: DefaultDispatcherProvider): DispatcherProvider + + companion object { + @Provides + @Singleton + fun provideFirebaseAuth(): FirebaseAuth = FirebaseAuth.getInstance() + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/mapper/DtoMappers.kt b/core/data/src/main/kotlin/app/worktrack/core/data/mapper/DtoMappers.kt new file mode 100644 index 0000000..fc31100 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/mapper/DtoMappers.kt @@ -0,0 +1,284 @@ +package app.worktrack.core.data.mapper + +import app.worktrack.core.database.entity.AnnouncementEntity +import app.worktrack.core.database.entity.AttendanceDayEntity +import app.worktrack.core.database.entity.AttendancePunchEntity +import app.worktrack.core.database.entity.BranchEntity +import app.worktrack.core.database.entity.EmployeeEntity +import app.worktrack.core.database.entity.GeofenceEntity +import app.worktrack.core.database.entity.LeaveBalanceEntity +import app.worktrack.core.database.entity.LeaveRequestEntity +import app.worktrack.core.database.entity.LeaveTypeEntity +import app.worktrack.core.database.entity.PayslipEntity +import app.worktrack.core.database.entity.ProjectEntity +import app.worktrack.core.database.entity.TaskEntity +import app.worktrack.core.database.entity.PayslipLineEntity +import app.worktrack.core.database.entity.ShiftAssignmentEntity +import app.worktrack.core.database.entity.ShiftEntity +import app.worktrack.core.model.CompanyFeatures +import app.worktrack.core.model.LeaveStatus +import app.worktrack.core.model.PunchMethod +import app.worktrack.core.model.PunchType +import app.worktrack.core.model.RoleCode +import app.worktrack.core.model.SyncStatus +import app.worktrack.core.model.UserSession +import java.time.LocalDate +import app.worktrack.core.network.dto.AnnouncementDto +import app.worktrack.core.network.dto.AttendanceDayDto +import app.worktrack.core.network.dto.BranchDto +import app.worktrack.core.network.dto.EmployeeDto +import app.worktrack.core.network.dto.GeofenceDto +import app.worktrack.core.network.dto.LeaveBalanceDto +import app.worktrack.core.network.dto.LeaveRequestDto +import app.worktrack.core.network.dto.LeaveTypeDto +import app.worktrack.core.network.dto.MeDto +import app.worktrack.core.network.dto.PayslipDto +import app.worktrack.core.network.dto.ProjectDto +import app.worktrack.core.network.dto.WorkTaskDto +import app.worktrack.core.network.dto.PunchDto +import app.worktrack.core.network.dto.ShiftAssignmentDto +import app.worktrack.core.network.dto.ShiftDto +import java.time.LocalTime + +/** Server DTO -> Room entity. Server rows always land as SYNCED. */ + +private inline fun > String.toEnumOr(default: T): T = + enumValues().firstOrNull { it.name == this } ?: default + +fun MeDto.toSession() = UserSession( + uid = uid, + companyId = companyId, + employeeId = employeeId, + displayName = displayName, + email = email, + avatarUrl = avatarUrl, + roles = roles.mapNotNull(RoleCode::fromCode).toSet(), + branchIds = branchIds, + companyName = companyName, + features = CompanyFeatures( + shifts = features.shifts, + leave = features.leave, + payroll = features.payroll, + regularization = features.regularization, + announcements = features.announcements, + geofencing = features.geofencing, + qrKiosk = features.qrKiosk, + faceRecognition = features.faceRecognition, + ), +) + +fun BranchDto.toEntity() = BranchEntity( + id = id, + companyId = companyId, + name = name, + code = code, + address = address, + latitude = latitude, + longitude = longitude, + radiusMeters = radiusMeters, + timezone = timezone, + updatedAt = updatedAt, +) + +fun GeofenceDto.toEntity() = GeofenceEntity( + id = id, + companyId = companyId, + branchId = branchId, + name = name, + latitude = latitude, + longitude = longitude, + radiusMeters = radiusMeters, + active = active, + updatedAt = updatedAt, +) + +fun EmployeeDto.toEntity() = EmployeeEntity( + id = id, + companyId = companyId, + employeeCode = employeeCode, + firstName = firstName, + lastName = lastName, + email = email, + phone = phone, + avatarUrl = avatarUrl, + branchId = branchId, + departmentId = departmentId, + positionId = positionId, + managerId = managerId, + employmentType = employmentType, + joinDateEpochDay = joinDate.toEpochDay(), + status = status, + updatedAt = updatedAt, +) + +fun ShiftDto.toEntity() = ShiftEntity( + id = id, + companyId = companyId, + name = name, + code = code, + startTimeSecondOfDay = LocalTime.parse(startTime).toSecondOfDay(), + endTimeSecondOfDay = LocalTime.parse(endTime).toSecondOfDay(), + breakMinutes = breakMinutes, + graceInMinutes = graceInMinutes, + graceOutMinutes = graceOutMinutes, + isNightShift = isNightShift, + active = active, + updatedAt = updatedAt, +) + +fun ShiftAssignmentDto.toEntity() = ShiftAssignmentEntity( + id = id, + companyId = companyId, + employeeId = employeeId, + shiftId = shiftId, + date = date, + branchId = branchId, + source = source, + updatedAt = updatedAt, +) + +fun PunchDto.toEntity() = AttendancePunchEntity( + id = id, + companyId = companyId, + employeeId = employeeId, + punchedAt = punchedAt, + type = type.toEnumOr(PunchType.IN), + method = method.toEnumOr(PunchMethod.MANUAL), + latitude = latitude, + longitude = longitude, + accuracyMeters = accuracyMeters, + geofenceId = geofenceId, + insideFence = insideFence, + note = note, + serverValidated = serverValidated, + invalidReason = invalidReason, + syncStatus = SyncStatus.SYNCED, +) + +fun AttendanceDayDto.toEntity() = AttendanceDayEntity( + id = id, + employeeId = employeeId, + date = date, + shiftId = shiftId, + firstInAt = firstInAt, + lastOutAt = lastOutAt, + workedMinutes = workedMinutes, + lateMinutes = lateMinutes, + earlyOutMinutes = earlyOutMinutes, + overtimeMinutes = overtimeMinutes, + status = status, +) + +fun LeaveTypeDto.toEntity() = LeaveTypeEntity( + id = id, + companyId = companyId, + name = name, + code = code, + colorHex = colorHex, + isPaid = isPaid, + requiresAttachment = requiresAttachment, + active = active, + updatedAt = updatedAt, +) + +fun LeaveBalanceDto.toEntity() = LeaveBalanceEntity( + id = id, + employeeId = employeeId, + leaveTypeId = leaveTypeId, + periodYear = periodYear, + entitledDays = entitledDays, + accruedDays = accruedDays, + usedDays = usedDays, + carriedOverDays = carriedOverDays, + pendingDays = pendingDays, + updatedAt = updatedAt, +) + +fun LeaveRequestDto.toEntity(syncStatus: SyncStatus = SyncStatus.SYNCED) = LeaveRequestEntity( + id = id, + companyId = companyId, + employeeId = employeeId, + employeeName = employeeName, + leaveTypeId = leaveTypeId, + startDate = startDate, + endDate = endDate, + startHalfDay = startHalfDay, + endHalfDay = endHalfDay, + days = days, + reason = reason, + status = status.toEnumOr(LeaveStatus.PENDING), + currentApproverId = currentApproverId, + decidedAt = decidedAt, + decisionNote = decisionNote, + createdAt = createdAt, + updatedAt = updatedAt, + syncStatus = syncStatus, +) + +fun PayslipDto.toEntity() = PayslipEntity( + id = id, + companyId = companyId, + runId = runId, + employeeId = employeeId, + periodYear = periodYear, + periodMonth = periodMonth, + currency = currency, + gross = gross, + totalDeductions = totalDeductions, + net = net, + workedDays = workedDays, + paidLeaveDays = paidLeaveDays, + lopDays = lopDays, + overtimeMinutes = overtimeMinutes, + status = status, + pdfUrl = pdfUrl, + updatedAt = updatedAt, +) + +fun PayslipDto.toLineEntities(): List = lines.map { + PayslipLineEntity( + payslipId = id, + componentCode = it.componentCode, + componentName = it.componentName, + type = it.type, + amount = it.amount, + ) +} + +fun AnnouncementDto.toEntity() = AnnouncementEntity( + id = id, + companyId = companyId, + title = title, + body = body, + priority = priority, + publishedAt = publishedAt, + expiresAt = expiresAt, + createdByName = createdByName, + updatedAt = updatedAt, +) + +// ------------------------------------------------------------------- work + +fun ProjectDto.toEntity() = ProjectEntity( + id = id, + name = name, + code = code, + status = status, + updatedAt = updatedAt, +) + +fun WorkTaskDto.toEntity() = TaskEntity( + id = id, + projectId = projectId, + projectName = projectName, + title = title, + detail = detail, + location = location, + startDate = LocalDate.parse(startDate), + endDate = LocalDate.parse(endDate), + status = status, + priority = priority, + teamName = teamName, + assigneeNames = assigneeNames, + updatedAt = updatedAt, +) diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/mapper/EntityMappers.kt b/core/data/src/main/kotlin/app/worktrack/core/data/mapper/EntityMappers.kt new file mode 100644 index 0000000..85cd047 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/mapper/EntityMappers.kt @@ -0,0 +1,247 @@ +package app.worktrack.core.data.mapper + +import app.worktrack.core.database.entity.AnnouncementEntity +import app.worktrack.core.database.entity.AttendanceDayEntity +import app.worktrack.core.database.entity.AttendancePunchEntity +import app.worktrack.core.database.entity.BranchEntity +import app.worktrack.core.database.entity.EmployeeEntity +import app.worktrack.core.database.entity.GeofenceEntity +import app.worktrack.core.database.entity.LeaveBalanceEntity +import app.worktrack.core.database.entity.LeaveRequestEntity +import app.worktrack.core.database.entity.LeaveTypeEntity +import app.worktrack.core.database.entity.PayslipWithLines +import app.worktrack.core.database.entity.ProjectEntity +import app.worktrack.core.database.entity.TaskEntity +import app.worktrack.core.database.entity.ShiftEntity +import app.worktrack.core.model.Announcement +import app.worktrack.core.model.AnnouncementPriority +import app.worktrack.core.model.AttendanceDay +import app.worktrack.core.model.AttendanceDayStatus +import app.worktrack.core.model.AttendancePunch +import app.worktrack.core.model.Branch +import app.worktrack.core.model.Employee +import app.worktrack.core.model.EmployeeStatus +import app.worktrack.core.model.EmploymentType +import app.worktrack.core.model.Geofence +import app.worktrack.core.model.LeaveBalance +import app.worktrack.core.model.LeaveRequest +import app.worktrack.core.model.LeaveType +import app.worktrack.core.model.PayComponentType +import app.worktrack.core.model.Project +import app.worktrack.core.model.TaskPriority +import app.worktrack.core.model.TaskStatus +import app.worktrack.core.model.WorkTask +import app.worktrack.core.model.Payslip +import app.worktrack.core.model.PayslipLine +import app.worktrack.core.model.PayslipStatus +import app.worktrack.core.model.Shift +import java.time.LocalDate +import java.time.LocalTime + +/** Room entity -> domain model. Unknown enum names degrade to safe defaults. */ + +private inline fun > String.toEnumOr(default: T): T = + enumValues().firstOrNull { it.name == this } ?: default + +fun BranchEntity.toModel() = Branch( + id = id, + companyId = companyId, + name = name, + code = code, + address = address, + latitude = latitude, + longitude = longitude, + radiusMeters = radiusMeters, + timezone = timezone, + updatedAt = updatedAt, +) + +fun GeofenceEntity.toModel() = Geofence( + id = id, + companyId = companyId, + branchId = branchId, + name = name, + latitude = latitude, + longitude = longitude, + radiusMeters = radiusMeters, + active = active, + updatedAt = updatedAt, +) + +fun EmployeeEntity.toModel() = Employee( + id = id, + companyId = companyId, + employeeCode = employeeCode, + firstName = firstName, + lastName = lastName, + email = email, + phone = phone, + avatarUrl = avatarUrl, + branchId = branchId, + departmentId = departmentId, + positionId = positionId, + managerId = managerId, + employmentType = employmentType.toEnumOr(EmploymentType.FULL_TIME), + joinDate = LocalDate.ofEpochDay(joinDateEpochDay), + status = status.toEnumOr(EmployeeStatus.ACTIVE), + updatedAt = updatedAt, +) + +fun AttendancePunchEntity.toModel() = AttendancePunch( + id = id, + companyId = companyId, + employeeId = employeeId, + punchedAt = punchedAt, + type = type, + method = method, + latitude = latitude, + longitude = longitude, + accuracyMeters = accuracyMeters, + geofenceId = geofenceId, + insideFence = insideFence, + note = note, + serverValidated = serverValidated, + invalidReason = invalidReason, + syncStatus = syncStatus, +) + +fun AttendanceDayEntity.toModel() = AttendanceDay( + id = id, + employeeId = employeeId, + date = date, + shiftId = shiftId, + firstInAt = firstInAt, + lastOutAt = lastOutAt, + workedMinutes = workedMinutes, + lateMinutes = lateMinutes, + earlyOutMinutes = earlyOutMinutes, + overtimeMinutes = overtimeMinutes, + status = status.toEnumOr(AttendanceDayStatus.PENDING), +) + +fun ShiftEntity.toModel() = Shift( + id = id, + companyId = companyId, + name = name, + code = code, + startTime = LocalTime.ofSecondOfDay(startTimeSecondOfDay.toLong()), + endTime = LocalTime.ofSecondOfDay(endTimeSecondOfDay.toLong()), + breakMinutes = breakMinutes, + graceInMinutes = graceInMinutes, + graceOutMinutes = graceOutMinutes, + isNightShift = isNightShift, + active = active, + updatedAt = updatedAt, +) + +fun LeaveTypeEntity.toModel() = LeaveType( + id = id, + companyId = companyId, + name = name, + code = code, + colorHex = colorHex, + isPaid = isPaid, + requiresAttachment = requiresAttachment, + active = active, + updatedAt = updatedAt, +) + +fun LeaveBalanceEntity.toModel() = LeaveBalance( + id = id, + employeeId = employeeId, + leaveTypeId = leaveTypeId, + periodYear = periodYear, + entitledDays = entitledDays, + accruedDays = accruedDays, + usedDays = usedDays, + carriedOverDays = carriedOverDays, + pendingDays = pendingDays, + updatedAt = updatedAt, +) + +fun LeaveRequestEntity.toModel() = LeaveRequest( + id = id, + companyId = companyId, + employeeId = employeeId, + employeeName = employeeName, + leaveTypeId = leaveTypeId, + startDate = startDate, + endDate = endDate, + startHalfDay = startHalfDay, + endHalfDay = endHalfDay, + days = days, + reason = reason, + status = status, + currentApproverId = currentApproverId, + decidedAt = decidedAt, + decisionNote = decisionNote, + createdAt = createdAt, + updatedAt = updatedAt, + syncStatus = syncStatus, +) + +fun PayslipWithLines.toModel() = Payslip( + id = payslip.id, + companyId = payslip.companyId, + runId = payslip.runId, + employeeId = payslip.employeeId, + periodYear = payslip.periodYear, + periodMonth = payslip.periodMonth, + currency = payslip.currency, + gross = payslip.gross, + totalDeductions = payslip.totalDeductions, + net = payslip.net, + workedDays = payslip.workedDays, + paidLeaveDays = payslip.paidLeaveDays, + lopDays = payslip.lopDays, + overtimeMinutes = payslip.overtimeMinutes, + status = payslip.status.toEnumOr(PayslipStatus.FINALIZED), + pdfUrl = payslip.pdfUrl, + lines = lines.map { + PayslipLine( + componentCode = it.componentCode, + componentName = it.componentName, + type = it.type.toEnumOr(PayComponentType.EARNING), + amount = it.amount, + ) + }, + updatedAt = payslip.updatedAt, +) + +fun AnnouncementEntity.toModel() = Announcement( + id = id, + companyId = companyId, + title = title, + body = body, + priority = priority.toEnumOr(AnnouncementPriority.NORMAL), + publishedAt = publishedAt, + expiresAt = expiresAt, + createdByName = createdByName, + updatedAt = updatedAt, +) + +// ------------------------------------------------------------------- work + +fun ProjectEntity.toModel() = Project( + id = id, + name = name, + code = code, + status = status, + updatedAt = updatedAt, +) + +fun TaskEntity.toModel() = WorkTask( + id = id, + projectId = projectId, + projectName = projectName, + title = title, + detail = detail, + location = location, + startDate = startDate, + endDate = endDate, + status = status.toEnumOr(TaskStatus.PLANNED), + priority = priority.toEnumOr(TaskPriority.NORMAL), + teamName = teamName, + assigneeNames = assigneeNames, + updatedAt = updatedAt, +) diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/AnnouncementRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AnnouncementRepositoryImpl.kt new file mode 100644 index 0000000..4e137e7 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AnnouncementRepositoryImpl.kt @@ -0,0 +1,39 @@ +package app.worktrack.core.data.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.map +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.data.mapper.toEntity +import app.worktrack.core.data.mapper.toModel +import app.worktrack.core.database.dao.AnnouncementDao +import app.worktrack.core.domain.repository.AnnouncementRepository +import app.worktrack.core.model.Announcement +import app.worktrack.core.network.WorkTrackApi +import app.worktrack.core.network.apiCall +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map + +@Singleton +class AnnouncementRepositoryImpl @Inject constructor( + private val announcementDao: AnnouncementDao, + private val api: WorkTrackApi, + private val timeProvider: TimeProvider, +) : AnnouncementRepository { + + override fun observeAnnouncements(): Flow> = + // 'now' is captured per collection so returning to the screen re-filters + // expired announcements without needing a DB write. + flow { + emitAll(announcementDao.observeActive(timeProvider.now())) + }.map { items -> items.map { it.toModel() } } + + override suspend fun refresh(): AppResult = + apiCall { api.announcements() } + .map { envelope -> + announcementDao.upsertAnnouncements(envelope.data.map { it.toEntity() }) + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/AttendanceRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AttendanceRepositoryImpl.kt new file mode 100644 index 0000000..a8ed388 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AttendanceRepositoryImpl.kt @@ -0,0 +1,212 @@ +package app.worktrack.core.data.repository + +import app.worktrack.core.common.id.Ulid +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.map +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.data.mapper.toEntity +import app.worktrack.core.data.mapper.toModel +import app.worktrack.core.data.sync.OutboxOpTypes +import app.worktrack.core.data.sync.OutboxWriter +import app.worktrack.core.data.sync.ResourceTypes +import app.worktrack.core.database.dao.AttendanceDao +import app.worktrack.core.database.dao.OrgDao +import app.worktrack.core.database.dao.ShiftDao +import app.worktrack.core.database.entity.AttendancePunchEntity +import app.worktrack.core.datastore.SessionStore +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.model.AttendanceDay +import app.worktrack.core.model.AttendancePunch +import app.worktrack.core.model.Geofence +import app.worktrack.core.model.PunchCommand +import app.worktrack.core.model.PunchType +import app.worktrack.core.model.RegularizationCommand +import app.worktrack.core.model.SyncStatus +import app.worktrack.core.model.TodayAttendance +import app.worktrack.core.network.WorkTrackApi +import app.worktrack.core.network.apiCall +import app.worktrack.core.network.dto.PunchCreateDto +import app.worktrack.core.network.dto.RegularizationCreateDto +import java.time.Duration +import java.time.LocalDate +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json + +@Singleton +class AttendanceRepositoryImpl @Inject constructor( + private val attendanceDao: AttendanceDao, + private val shiftDao: ShiftDao, + private val orgDao: OrgDao, + private val sessionStore: SessionStore, + private val outboxWriter: OutboxWriter, + private val api: WorkTrackApi, + private val timeProvider: TimeProvider, + private val json: Json, +) : AttendanceRepository { + + override fun observeToday(): Flow = + sessionStore.session.flatMapLatest { session -> + val today = timeProvider.today() + if (session == null) { + flowOf(emptyToday(today)) + } else { + val zone = timeProvider.zone() + val dayStart = today.atStartOfDay(zone).toInstant() + val dayEnd = today.plusDays(1).atStartOfDay(zone).toInstant() + combine( + attendanceDao.observePunchesBetween(session.employeeId, dayStart, dayEnd), + shiftDao.observeShiftForDate(session.employeeId, today), + ) { punches, shift -> + val ordered = punches.sortedBy { it.punchedAt } + TodayAttendance( + date = today, + clockedIn = ordered.lastOrNull()?.type == PunchType.IN, + firstInAt = ordered.firstOrNull { it.type == PunchType.IN }?.punchedAt, + lastPunchAt = ordered.lastOrNull()?.punchedAt, + punchCount = ordered.size, + workedMinutesSoFar = closedPairMinutes(ordered), + shift = shift?.toModel(), + ) + } + } + } + + override fun observeDays(from: LocalDate, to: LocalDate): Flow> = + sessionStore.session.flatMapLatest { session -> + if (session == null) { + flowOf(emptyList()) + } else { + attendanceDao.observeDaysBetween(session.employeeId, from, to) + .map { days -> days.map { it.toModel() } } + } + } + + override fun observePunches(from: LocalDate, to: LocalDate): Flow> = + sessionStore.session.flatMapLatest { session -> + if (session == null) { + flowOf(emptyList()) + } else { + val zone = timeProvider.zone() + attendanceDao.observePunchesBetween( + employeeId = session.employeeId, + from = from.atStartOfDay(zone).toInstant(), + to = to.plusDays(1).atStartOfDay(zone).toInstant(), + ).map { punches -> punches.map { it.toModel() } } + } + } + + override fun observeActiveGeofences(): Flow> = + orgDao.observeActiveGeofences().map { fences -> fences.map { it.toModel() } } + + override suspend fun punch(command: PunchCommand): AppResult { + val session = sessionStore.session.first() + ?: return AppResult.failure(AppError.Unauthenticated) + + val entity = AttendancePunchEntity( + id = Ulid.generate(timeProvider.now().toEpochMilli()), + companyId = session.companyId, + employeeId = session.employeeId, + punchedAt = timeProvider.now(), + type = command.type, + method = command.method, + latitude = command.latitude, + longitude = command.longitude, + accuracyMeters = command.accuracyMeters, + geofenceId = command.geofenceId, + insideFence = command.insideFence, + note = command.note, + serverValidated = false, + invalidReason = null, + syncStatus = SyncStatus.PENDING, + ) + attendanceDao.insertPunch(entity) + + val payload = PunchCreateDto( + id = entity.id, + punchedAt = entity.punchedAt, + type = entity.type.name, + method = entity.method.name, + latitude = entity.latitude, + longitude = entity.longitude, + accuracyMeters = entity.accuracyMeters, + geofenceId = entity.geofenceId, + insideFence = entity.insideFence, + kioskToken = command.kioskToken, + note = entity.note, + selfie = command.selfie, + faceToken = command.faceToken, + ) + outboxWriter.enqueue( + opType = OutboxOpTypes.CREATE, + resourceType = ResourceTypes.PUNCHES, + resourceId = entity.id, + payloadJson = json.encodeToString(PunchCreateDto.serializer(), payload), + ) + return AppResult.success(entity.toModel()) + } + + override suspend fun requestRegularization( + command: RegularizationCommand, + ): AppResult { + // Require an authenticated session; the server stamps company/employee + // from the auth token when the outbox op is pushed. + sessionStore.session.first() + ?: return AppResult.failure(AppError.Unauthenticated) + + val id = Ulid.generate(timeProvider.now().toEpochMilli()) + val payload = RegularizationCreateDto( + id = id, + date = command.date, + requestedInAt = command.requestedInAt, + requestedOutAt = command.requestedOutAt, + reason = command.reason.trim(), + ) + outboxWriter.enqueue( + opType = OutboxOpTypes.CREATE, + resourceType = ResourceTypes.REGULARIZATIONS, + resourceId = id, + payloadJson = json.encodeToString(RegularizationCreateDto.serializer(), payload), + ) + return AppResult.success(Unit) + } + + override suspend fun refresh(from: LocalDate, to: LocalDate): AppResult = + apiCall { api.attendanceDays(from.toString(), to.toString()) } + .map { envelope -> + attendanceDao.upsertDays(envelope.data.map { it.toEntity() }) + } + + private fun emptyToday(today: LocalDate) = TodayAttendance( + date = today, + clockedIn = false, + firstInAt = null, + lastPunchAt = null, + punchCount = 0, + workedMinutesSoFar = 0, + shift = null, + ) + + /** Sums completed IN→OUT intervals; an open IN is counted live by the UI clock. */ + private fun closedPairMinutes(ordered: List): Int { + var total = 0L + var openIn: AttendancePunchEntity? = null + for (punch in ordered) { + when (punch.type) { + PunchType.IN -> if (openIn == null) openIn = punch + PunchType.OUT -> openIn?.let { + total += Duration.between(it.punchedAt, punch.punchedAt).toMinutes() + openIn = null + } + } + } + return total.toInt() + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..9a7b08e --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,135 @@ +package app.worktrack.core.data.repository + +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.map +import app.worktrack.core.common.result.onFailure +import app.worktrack.core.common.result.onSuccess +import app.worktrack.core.data.mapper.toSession +import app.worktrack.core.database.DatabaseCleaner +import app.worktrack.core.datastore.SessionStore +import app.worktrack.core.domain.repository.AuthRepository +import app.worktrack.core.model.UserSession +import app.worktrack.core.network.WorkTrackApi +import app.worktrack.core.network.apiCall +import app.worktrack.core.network.di.ApiConfig +import com.google.firebase.FirebaseNetworkException +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException +import com.google.firebase.auth.FirebaseAuthInvalidUserException +import dagger.Lazy +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.tasks.await + +@Singleton +class AuthRepositoryImpl @Inject constructor( + // Lazy so FirebaseAuth.getInstance() is NOT called while the Hilt graph is + // built at app launch. Without it, a missing/invalid google-services.json + // would crash the app on startup instead of failing only at sign-in. + private val firebaseAuthProvider: Lazy, + private val api: WorkTrackApi, + private val apiConfig: ApiConfig, + private val sessionStore: SessionStore, + // DatabaseCleaner (not WorkTrackDatabase) so this module needs no Room on + // its classpath; see DatabaseCleaner's doc for the rationale. + private val databaseCleaner: DatabaseCleaner, +) : AuthRepository { + + private val firebaseAuth: FirebaseAuth get() = firebaseAuthProvider.get() + + override val session: Flow = sessionStore.session + + override val biometricLockEnabled: Flow = sessionStore.biometricLockEnabled + + override suspend fun setBiometricLock(enabled: Boolean) = + sessionStore.setBiometricLock(enabled) + + override suspend fun signIn(email: String, password: String): AppResult { + try { + firebaseAuth.signInWithEmailAndPassword(email, password).await() + } catch (e: CancellationException) { + throw e + } catch (e: FirebaseAuthInvalidUserException) { + return AppResult.failure(invalidCredentials()) + } catch (e: FirebaseAuthInvalidCredentialsException) { + return AppResult.failure(invalidCredentials()) + } catch (e: FirebaseNetworkException) { + return AppResult.failure(AppError.Network) + } catch (e: Exception) { + // Firebase wraps a failure to reach the auth backend in a generic + // "internal error", which used to surface as "something went wrong" + // and told nobody anything. A build pointed at the local emulators + // hits this the moment they are not running, so say so plainly + // instead of leaving a developer staring at the login screen. + return AppResult.failure(unreachableOrUnexpected(e)) + } + + // Resolve tenant context. A Firebase account without a provisioned + // employee (no /me) must not end up half signed in. + return apiCall { api.me() } + .map { it.data.toSession() } + .onSuccess { sessionStore.save(it) } + .onFailure { firebaseAuth.signOut() } + } + + override suspend fun refreshSession(): AppResult = + apiCall { api.me() } + .map { it.data.toSession() } + .onSuccess { sessionStore.save(it) } + + override suspend fun sendPasswordReset(email: String): AppResult = try { + firebaseAuth.sendPasswordResetEmail(email.trim()).await() + AppResult.success(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: FirebaseNetworkException) { + AppResult.failure(AppError.Network) + } catch (e: Exception) { + // Do not reveal whether the account exists (user enumeration). + AppResult.success(Unit) + } + + override suspend fun signOut() { + firebaseAuth.signOut() + sessionStore.clear() + // Tenant data never survives a sign-out on shared devices. + databaseCleaner.clearAllTenantData() + } + + /** True when the throwable, or anything under it, is a connection failure. */ + private fun isConnectionFailure(e: Throwable): Boolean { + var t: Throwable? = e + while (t != null) { + val m = t.message.orEmpty() + if (m.contains("Failed to connect", ignoreCase = true) || + m.contains("Unable to resolve host", ignoreCase = true) || + m.contains("ECONNREFUSED", ignoreCase = true) + ) { + return true + } + t = t.cause + } + return false + } + + private fun unreachableOrUnexpected(e: Exception): AppError = when { + !isConnectionFailure(e) -> AppError.Unexpected(e) + // Only a developer ever sees this, and only they can fix it. + // Kept to one short line: this renders inside a right-to-left layout, + // and a long Latin sentence gets its tail reordered by the bidi + // algorithm into something unreadable. + apiConfig.useEmulators -> AppError.Business( + "EMULATOR_UNREACHABLE", + "Local Firebase emulators are not running.", + ) + else -> AppError.Network + } + + private fun invalidCredentials() = AppError.Business( + code = "INVALID_CREDENTIALS", + message = "Email or password is incorrect", + ) +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/FaceRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/FaceRepositoryImpl.kt new file mode 100644 index 0000000..58ce62d --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/FaceRepositoryImpl.kt @@ -0,0 +1,28 @@ +package app.worktrack.core.data.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.map +import app.worktrack.core.domain.repository.FaceRepository +import app.worktrack.core.domain.repository.FaceVerification +import app.worktrack.core.network.WorkTrackApi +import app.worktrack.core.network.apiCall +import app.worktrack.core.network.dto.FaceEmbeddingDto +import javax.inject.Inject + +class FaceRepositoryImpl @Inject constructor( + private val api: WorkTrackApi, +) : FaceRepository { + + override suspend fun enroll(embedding: List): AppResult = + apiCall { api.enrollFace(FaceEmbeddingDto(embedding)) }.map { } + + override suspend fun verify(embedding: List): AppResult = + apiCall { api.verifyFace(FaceEmbeddingDto(embedding)) }.map { env -> + FaceVerification( + match = env.data.match, + similarity = env.data.similarity, + enrolled = env.data.enrolled, + token = env.data.token, + ) + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/LeaveRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/LeaveRepositoryImpl.kt new file mode 100644 index 0000000..207b984 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/LeaveRepositoryImpl.kt @@ -0,0 +1,179 @@ +package app.worktrack.core.data.repository + +import app.worktrack.core.common.id.Ulid +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.map +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.data.mapper.toEntity +import app.worktrack.core.data.mapper.toModel +import app.worktrack.core.data.sync.OutboxOpTypes +import app.worktrack.core.data.sync.OutboxWriter +import app.worktrack.core.data.sync.ResourceTypes +import app.worktrack.core.database.dao.LeaveDao +import app.worktrack.core.database.entity.LeaveRequestEntity +import app.worktrack.core.datastore.SessionStore +import app.worktrack.core.domain.repository.LeaveRepository +import app.worktrack.core.domain.usecase.leave.ApplyLeaveUseCase +import app.worktrack.core.model.ApprovalDecision +import app.worktrack.core.model.LeaveApplication +import app.worktrack.core.model.LeaveBalance +import app.worktrack.core.model.LeaveRequest +import app.worktrack.core.model.LeaveStatus +import app.worktrack.core.model.LeaveType +import app.worktrack.core.model.SyncStatus +import app.worktrack.core.network.WorkTrackApi +import app.worktrack.core.network.apiCall +import app.worktrack.core.network.dto.LeaveDecisionDto +import app.worktrack.core.network.dto.LeaveRequestCreateDto +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json + +@Singleton +class LeaveRepositoryImpl @Inject constructor( + private val leaveDao: LeaveDao, + private val sessionStore: SessionStore, + private val outboxWriter: OutboxWriter, + private val api: WorkTrackApi, + private val timeProvider: TimeProvider, + private val json: Json, +) : LeaveRepository { + + override fun observeTypes(): Flow> = + leaveDao.observeActiveTypes().map { types -> types.map { it.toModel() } } + + override fun observeMyBalances(periodYear: Int): Flow> = + sessionStore.session.flatMapLatest { session -> + if (session == null) { + flowOf(emptyList()) + } else { + leaveDao.observeBalances(session.employeeId, periodYear) + .map { balances -> balances.map { it.toModel() } } + } + } + + override fun observeMyRequests(): Flow> = + sessionStore.session.flatMapLatest { session -> + if (session == null) { + flowOf(emptyList()) + } else { + leaveDao.observeMyRequests(session.employeeId) + .map { requests -> requests.map { it.toModel() } } + } + } + + override fun observePendingApprovals(): Flow> = + sessionStore.session.flatMapLatest { session -> + if (session == null || !session.isApprover) { + flowOf(emptyList()) + } else { + leaveDao.observePendingApprovals(session.employeeId) + .map { requests -> requests.map { it.toModel() } } + } + } + + override suspend fun apply(application: LeaveApplication): AppResult { + val session = sessionStore.session.first() + ?: return AppResult.failure(AppError.Unauthenticated) + + val now = timeProvider.now() + val entity = LeaveRequestEntity( + id = Ulid.generate(now.toEpochMilli()), + companyId = session.companyId, + employeeId = session.employeeId, + employeeName = session.displayName, + leaveTypeId = application.leaveTypeId, + startDate = application.startDate, + endDate = application.endDate, + startHalfDay = application.startHalfDay, + endHalfDay = application.endHalfDay, + days = ApplyLeaveUseCase.calculateDays(application), + reason = application.reason.trim(), + status = LeaveStatus.PENDING, + currentApproverId = null, // resolved server-side from the approval chain + decidedAt = null, + decisionNote = null, + createdAt = now, + updatedAt = now, + syncStatus = SyncStatus.PENDING, + ) + leaveDao.upsertRequests(listOf(entity)) + + val payload = LeaveRequestCreateDto( + id = entity.id, + leaveTypeId = entity.leaveTypeId, + startDate = entity.startDate, + endDate = entity.endDate, + startHalfDay = entity.startHalfDay, + endHalfDay = entity.endHalfDay, + reason = entity.reason, + ) + outboxWriter.enqueue( + opType = OutboxOpTypes.CREATE, + resourceType = ResourceTypes.LEAVE_REQUESTS, + resourceId = entity.id, + payloadJson = json.encodeToString(LeaveRequestCreateDto.serializer(), payload), + ) + return AppResult.success(entity.toModel()) + } + + override suspend fun cancel(requestId: String): AppResult { + val existing = leaveDao.requestById(requestId) + ?: return AppResult.failure(AppError.NotFound) + if (existing.syncStatus != SyncStatus.SYNCED) { + return AppResult.failure( + AppError.Business( + code = "NOT_SYNCED", + message = "Wait for this request to finish syncing before cancelling", + ), + ) + } + return apiCall { api.cancelLeaveRequest(requestId, idempotencyKey = Ulid.generate()) } + .map { envelope -> + leaveDao.upsertRequests(listOf(envelope.data.toEntity())) + } + } + + override suspend fun decide( + requestId: String, + decision: ApprovalDecision, + note: String?, + ): AppResult = + apiCall { + api.decideLeaveRequest( + requestId = requestId, + body = LeaveDecisionDto(decision = decision.name, note = note), + idempotencyKey = Ulid.generate(), + ) + }.map { envelope -> + leaveDao.upsertRequests(listOf(envelope.data.toEntity())) + } + + override suspend fun refresh(): AppResult { + val mine = apiCall { api.leaveRequests(scope = "mine") } + val approvals = apiCall { api.leaveRequests(scope = "approvals") } + + listOf(mine, approvals).forEach { result -> + if (result is AppResult.Success) { + result.data.data.forEach { dto -> + // Never clobber a locally created request that hasn't pushed yet. + val local = leaveDao.requestById(dto.id) + if (local == null || local.syncStatus != SyncStatus.PENDING) { + leaveDao.upsertRequests(listOf(dto.toEntity())) + } + } + } + } + return when { + mine is AppResult.Failure -> mine + approvals is AppResult.Failure -> approvals + else -> AppResult.success(Unit) + } + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/PayslipRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/PayslipRepositoryImpl.kt new file mode 100644 index 0000000..cb3bfb2 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/PayslipRepositoryImpl.kt @@ -0,0 +1,48 @@ +package app.worktrack.core.data.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.map +import app.worktrack.core.data.mapper.toEntity +import app.worktrack.core.data.mapper.toLineEntities +import app.worktrack.core.data.mapper.toModel +import app.worktrack.core.database.dao.PayslipDao +import app.worktrack.core.datastore.SessionStore +import app.worktrack.core.domain.repository.PayslipRepository +import app.worktrack.core.model.Payslip +import app.worktrack.core.network.WorkTrackApi +import app.worktrack.core.network.apiCall +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +@Singleton +class PayslipRepositoryImpl @Inject constructor( + private val payslipDao: PayslipDao, + private val sessionStore: SessionStore, + private val api: WorkTrackApi, +) : PayslipRepository { + + override fun observePayslips(periodYear: Int): Flow> = + sessionStore.session.flatMapLatest { session -> + if (session == null) { + flowOf(emptyList()) + } else { + payslipDao.observePayslips(session.employeeId, periodYear) + .map { slips -> slips.map { it.toModel() } } + } + } + + override fun observePayslip(payslipId: String): Flow = + payslipDao.observePayslip(payslipId).map { it?.toModel() } + + override suspend fun refresh(periodYear: Int): AppResult = + apiCall { api.payslips(periodYear) } + .map { envelope -> + envelope.data.forEach { dto -> + payslipDao.replacePayslip(dto.toEntity(), dto.toLineEntities()) + } + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/SyncRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/SyncRepositoryImpl.kt new file mode 100644 index 0000000..bd02194 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/SyncRepositoryImpl.kt @@ -0,0 +1,367 @@ +package app.worktrack.core.data.repository + +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.data.mapper.toEntity +import app.worktrack.core.data.mapper.toLineEntities +import app.worktrack.core.data.sync.ResourceTypes +import app.worktrack.core.database.dao.AnnouncementDao +import app.worktrack.core.database.dao.AttendanceDao +import app.worktrack.core.database.dao.LeaveDao +import app.worktrack.core.database.dao.OrgDao +import app.worktrack.core.database.dao.OutboxDao +import app.worktrack.core.database.dao.PayslipDao +import app.worktrack.core.database.dao.ShiftDao +import app.worktrack.core.database.dao.SyncCursorDao +import app.worktrack.core.database.dao.WorkDao +import app.worktrack.core.database.entity.OutboxEntryEntity +import app.worktrack.core.database.entity.SyncCursorEntity +import app.worktrack.core.datastore.SessionStore +import app.worktrack.core.domain.repository.SyncRepository +import app.worktrack.core.model.SyncState +import app.worktrack.core.model.SyncStatus +import app.worktrack.core.network.WorkTrackApi +import app.worktrack.core.network.apiCall +import app.worktrack.core.network.dto.AnnouncementDto +import app.worktrack.core.network.dto.AttendanceDayDto +import app.worktrack.core.network.dto.BranchDto +import app.worktrack.core.network.dto.EmployeeDto +import app.worktrack.core.network.dto.GeofenceDto +import app.worktrack.core.network.dto.LeaveBalanceDto +import app.worktrack.core.network.dto.LeaveRequestDto +import app.worktrack.core.network.dto.LeaveTypeDto +import app.worktrack.core.network.dto.PayslipDto +import app.worktrack.core.network.dto.ProjectDto +import app.worktrack.core.network.dto.PunchDto +import app.worktrack.core.network.dto.ShiftAssignmentDto +import app.worktrack.core.network.dto.ShiftDto +import app.worktrack.core.network.dto.SyncOpDto +import app.worktrack.core.network.dto.SyncOpResultDto +import app.worktrack.core.network.dto.SyncPushRequestDto +import app.worktrack.core.network.dto.WorkTaskDto +import java.time.Duration +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject + +/** + * The client sync engine. + * + * Push: drains the outbox in FIFO batches through POST /sync/push. Transport + * failures requeue the batch untouched; per-op rejections are terminal and are + * reflected onto the owning row (never silently dropped). + * + * Pull: per-resource-type delta cursors through GET /sync/pull, reference data + * first. Cursors only advance after a page is fully applied, so a crash + * mid-page replays idempotently (all appliers are upserts / insert-ignore). + */ +@Singleton +class SyncRepositoryImpl @Inject constructor( + private val outboxDao: OutboxDao, + private val syncCursorDao: SyncCursorDao, + private val orgDao: OrgDao, + private val shiftDao: ShiftDao, + private val attendanceDao: AttendanceDao, + private val leaveDao: LeaveDao, + private val payslipDao: PayslipDao, + private val announcementDao: AnnouncementDao, + private val workDao: WorkDao, + private val sessionStore: SessionStore, + private val api: WorkTrackApi, + private val timeProvider: TimeProvider, + private val json: Json, +) : SyncRepository { + + private data class EngineState( + val isSyncing: Boolean = false, + val lastSuccessAt: Instant? = null, + val lastError: String? = null, + ) + + private val mutex = Mutex() + private val engineState = MutableStateFlow(EngineState()) + + override fun observeSyncState(): Flow = combine( + engineState, + outboxDao.observePendingCount(), + outboxDao.observeFailedCount(), + ) { state, pending, failed -> + SyncState( + isSyncing = state.isSyncing, + pendingOperations = pending, + failedOperations = failed, + lastSuccessAt = state.lastSuccessAt, + lastError = state.lastError, + ) + } + + override suspend fun syncNow(): AppResult = mutex.withLock { + if (sessionStore.session.first() == null) { + return AppResult.success(Unit) // signed out: nothing to sync + } + engineState.update { it.copy(isSyncing = true) } + + val result = runSyncCycle() + + engineState.update { + when (result) { + is AppResult.Success -> EngineState( + isSyncing = false, + lastSuccessAt = timeProvider.now(), + lastError = null, + ) + + is AppResult.Failure -> it.copy( + isSyncing = false, + lastError = result.error.toShortMessage(), + ) + } + } + result + } + + private suspend fun runSyncCycle(): AppResult { + pushOutbox().let { if (it is AppResult.Failure) return it } + pullDeltas().let { if (it is AppResult.Failure) return it } + attendanceDao.prunePunchesBefore(timeProvider.now().minus(PUNCH_RETENTION)) + workDao.pruneTasksBefore(timeProvider.today().minusDays(TASK_RETENTION_DAYS)) + return AppResult.success(Unit) + } + + // ------------------------------------------------------------------ push + + private suspend fun pushOutbox(): AppResult { + outboxDao.requeueInFlight() // recover from a previous process death + + while (true) { + val batch = outboxDao.nextPending(PUSH_BATCH_SIZE) + if (batch.isEmpty()) return AppResult.success(Unit) + outboxDao.markInFlight(batch.map { it.id }) + + val ops = batch.map { entry -> + SyncOpDto( + opId = entry.id, + opType = entry.opType, + resourceType = entry.resourceType, + resourceId = entry.resourceId, + idempotencyKey = entry.idempotencyKey, + payload = json.parseToJsonElement(entry.payloadJson).jsonObject, + ) + } + + when (val response = apiCall { api.syncPush(SyncPushRequestDto(ops)) }) { + is AppResult.Failure -> { + // Transport-level failure: nothing was durably rejected. + outboxDao.requeueInFlight() + return response + } + + is AppResult.Success -> { + val byId = batch.associateBy { it.id } + response.data.data.results.forEach { opResult -> + byId[opResult.opId]?.let { applyOpResult(it, opResult) } + } + } + } + } + } + + private suspend fun applyOpResult(entry: OutboxEntryEntity, result: SyncOpResultDto) { + when (result.status) { + "APPLIED" -> { + applyServerEcho(entry, result.resource) + outboxDao.delete(entry.id) + } + + else -> { + // Business rejection: terminal for this op. Keep the entry as + // FAILED for observability and mark the owning row. + applyRejection(entry, result) + outboxDao.markAttemptFailed( + id = entry.id, + state = "FAILED", + error = result.errorCode ?: result.message, + ) + } + } + } + + private suspend fun applyServerEcho(entry: OutboxEntryEntity, resource: JsonObject?) { + when (entry.resourceType) { + ResourceTypes.PUNCHES -> { + val dto = resource?.let { json.decodeFromJsonElement(PunchDto.serializer(), it) } + attendanceDao.updatePunchSyncResult( + id = entry.resourceId, + syncStatus = SyncStatus.SYNCED, + serverValidated = dto?.serverValidated ?: true, + invalidReason = dto?.invalidReason, + ) + } + + ResourceTypes.LEAVE_REQUESTS -> { + resource + ?.let { json.decodeFromJsonElement(LeaveRequestDto.serializer(), it) } + ?.let { leaveDao.upsertRequests(listOf(it.toEntity())) } + } + } + } + + private suspend fun applyRejection(entry: OutboxEntryEntity, result: SyncOpResultDto) { + val reason = result.errorCode ?: result.message ?: "REJECTED" + when (entry.resourceType) { + ResourceTypes.PUNCHES -> attendanceDao.updatePunchSyncResult( + id = entry.resourceId, + syncStatus = SyncStatus.FAILED, + serverValidated = false, + invalidReason = reason, + ) + + ResourceTypes.LEAVE_REQUESTS -> { + leaveDao.requestById(entry.resourceId)?.let { existing -> + leaveDao.updateRequestStatus( + id = existing.id, + status = existing.status, + syncStatus = SyncStatus.FAILED, + updatedAt = timeProvider.now(), + ) + } + } + } + } + + // ------------------------------------------------------------------ pull + + private suspend fun pullDeltas(): AppResult { + for (resourceType in ResourceTypes.pullOrder) { + var cursor = syncCursorDao.cursor(resourceType)?.cursor + while (true) { + val page = when (val res = apiCall { api.syncPull(resourceType, cursor) }) { + is AppResult.Failure -> return res + is AppResult.Success -> res.data.data + } + + if (page.items.isNotEmpty()) applyPulled(resourceType, page.items) + + val next = page.nextCursor + if (next != null && next != cursor) { + cursor = next + syncCursorDao.upsert( + SyncCursorEntity( + resourceType = resourceType, + cursor = next, + lastSyncedAt = timeProvider.now(), + ), + ) + } + if (!page.hasMore) break + } + } + return AppResult.success(Unit) + } + + private suspend fun applyPulled(resourceType: String, items: List) { + when (resourceType) { + ResourceTypes.BRANCHES -> orgDao.upsertBranches( + items.decode(BranchDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.GEOFENCES -> orgDao.upsertGeofences( + items.decode(GeofenceDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.EMPLOYEES -> orgDao.upsertEmployees( + items.decode(EmployeeDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.SHIFTS -> shiftDao.upsertShifts( + items.decode(ShiftDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.SHIFT_ASSIGNMENTS -> shiftDao.upsertAssignments( + items.decode(ShiftAssignmentDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.LEAVE_TYPES -> leaveDao.upsertTypes( + items.decode(LeaveTypeDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.LEAVE_BALANCES -> leaveDao.upsertBalances( + items.decode(LeaveBalanceDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.LEAVE_REQUESTS -> { + items.decode(LeaveRequestDto.serializer()).forEach { dto -> + // A locally created request that hasn't pushed yet wins. + val local = leaveDao.requestById(dto.id) + if (local == null || local.syncStatus != SyncStatus.PENDING) { + leaveDao.upsertRequests(listOf(dto.toEntity())) + } + } + } + + // insertPunches uses IGNORE: pending local punches are never clobbered, + // and replayed pages are no-ops. + ResourceTypes.PUNCHES -> attendanceDao.insertPunches( + items.decode(PunchDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.ATTENDANCE_DAYS -> attendanceDao.upsertDays( + items.decode(AttendanceDayDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.PAYSLIPS -> items.decode(PayslipDto.serializer()).forEach { dto -> + payslipDao.replacePayslip(dto.toEntity(), dto.toLineEntities()) + } + + ResourceTypes.ANNOUNCEMENTS -> announcementDao.upsertAnnouncements( + items.decode(AnnouncementDto.serializer()).map { it.toEntity() }, + ) + + ResourceTypes.PROJECTS -> workDao.upsertProjects( + items.decode(ProjectDto.serializer()).map { it.toEntity() }, + ) + + // Scoped to this employee by the server, so everything that arrives + // is his own work. + ResourceTypes.TASKS -> workDao.upsertTasks( + items.decode(WorkTaskDto.serializer()).map { it.toEntity() }, + ) + } + } + + private fun List.decode( + serializer: kotlinx.serialization.KSerializer, + ): List = mapNotNull { item -> + try { + json.decodeFromJsonElement(serializer, item) + } catch (e: kotlinx.serialization.SerializationException) { + null // One malformed document must not poison the whole page. + } + } + + private fun AppError.toShortMessage(): String = when (this) { + AppError.Network -> "Offline" + AppError.Unauthenticated -> "Session expired" + AppError.PermissionDenied -> "Permission denied" + is AppError.Http -> "Server error ($status)" + is AppError.Business -> code + else -> "Sync failed" + } + + private companion object { + const val PUSH_BATCH_SIZE = 50 + val PUNCH_RETENTION: Duration = Duration.ofDays(90) + const val TASK_RETENTION_DAYS = 60L + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/repository/WorkRepositoryImpl.kt b/core/data/src/main/kotlin/app/worktrack/core/data/repository/WorkRepositoryImpl.kt new file mode 100644 index 0000000..1a16273 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/repository/WorkRepositoryImpl.kt @@ -0,0 +1,58 @@ +package app.worktrack.core.data.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.map +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.data.mapper.toEntity +import app.worktrack.core.data.mapper.toModel +import app.worktrack.core.database.dao.WorkDao +import app.worktrack.core.domain.repository.WorkRepository +import app.worktrack.core.model.TaskStatus +import app.worktrack.core.model.WorkTask +import app.worktrack.core.network.WorkTrackApi +import app.worktrack.core.network.apiCall +import app.worktrack.core.network.dto.TaskStatusDto +import java.time.Duration +import java.time.LocalDate +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * The employee's own work. + * + * Reads come from Room, so the plan survives a day on a site with no signal — + * /work/mine needs a connection, and the answer to "what am I on" must not + * depend on having one. Which two days to show is the use case's decision, not + * this one's; here it is only storage. + */ +@Singleton +class WorkRepositoryImpl @Inject constructor( + private val workDao: WorkDao, + private val api: WorkTrackApi, + private val timeProvider: TimeProvider, +) : WorkRepository { + + override fun observeTasks(from: LocalDate, to: LocalDate): Flow> = + workDao.observeBetween(from, to).map { rows -> rows.map { it.toModel() } } + + override suspend fun setStatus(taskId: String, status: TaskStatus): AppResult = + apiCall { api.setTaskStatus(taskId, TaskStatusDto(status.name)) } + .map { envelope -> + // Write the server's answer back rather than what was asked for: + // if it refused the move, the row must not claim otherwise. + workDao.updateStatus(taskId, envelope.data.status, envelope.data.updatedAt) + } + + override suspend fun refresh(): AppResult = + apiCall { api.myWork() }.map { envelope -> + val days = listOfNotNull(envelope.data.today, envelope.data.next) + workDao.upsertTasks(days.flatMap { it.tasks }.map { it.toEntity() }) + workDao.pruneTasksBefore(timeProvider.today().minus(TASK_RETENTION)) + } + + private companion object { + val TASK_RETENTION: Duration = Duration.ofDays(60) + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/sync/OutboxWriter.kt b/core/data/src/main/kotlin/app/worktrack/core/data/sync/OutboxWriter.kt new file mode 100644 index 0000000..f836894 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/sync/OutboxWriter.kt @@ -0,0 +1,47 @@ +package app.worktrack.core.data.sync + +import app.worktrack.core.common.id.Ulid +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.database.dao.OutboxDao +import app.worktrack.core.database.entity.OutboxEntryEntity +import app.worktrack.core.domain.repository.SyncScheduler +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Single entry point for queueing offline mutations. Every enqueued operation + * carries a fresh ULID idempotency key so server-side replays are detectable. + */ +@Singleton +class OutboxWriter @Inject constructor( + private val outboxDao: OutboxDao, + private val timeProvider: TimeProvider, + private val syncScheduler: SyncScheduler, +) { + + suspend fun enqueue( + opType: String, + resourceType: String, + resourceId: String, + payloadJson: String, + ) { + outboxDao.insert( + OutboxEntryEntity( + id = Ulid.generate(), + opType = opType, + resourceType = resourceType, + resourceId = resourceId, + payloadJson = payloadJson, + idempotencyKey = Ulid.generate(), + attempts = 0, + lastError = null, + state = "PENDING", + queuedAt = timeProvider.now(), + ), + ) + // Push immediately so a punch (or any mutation) reaches the server right + // away instead of waiting for the ~15-min periodic sync window. The + // scheduler de-dupes concurrent requests (APPEND_OR_REPLACE). + syncScheduler.requestImmediateSync() + } +} diff --git a/core/data/src/main/kotlin/app/worktrack/core/data/sync/ResourceTypes.kt b/core/data/src/main/kotlin/app/worktrack/core/data/sync/ResourceTypes.kt new file mode 100644 index 0000000..0da4682 --- /dev/null +++ b/core/data/src/main/kotlin/app/worktrack/core/data/sync/ResourceTypes.kt @@ -0,0 +1,48 @@ +package app.worktrack.core.data.sync + +/** + * Wire names for replicated resource types. Must match the backend's sync + * registry (backend/functions/src/routes/sync.ts) exactly. + */ +object ResourceTypes { + const val BRANCHES = "branches" + const val GEOFENCES = "geofences" + const val EMPLOYEES = "employees" + const val SHIFTS = "shifts" + const val SHIFT_ASSIGNMENTS = "shiftAssignments" + const val PUNCHES = "punches" + const val ATTENDANCE_DAYS = "attendanceDays" + const val REGULARIZATIONS = "regularizations" + const val LEAVE_TYPES = "leaveTypes" + const val LEAVE_BALANCES = "leaveBalances" + const val LEAVE_REQUESTS = "leaveRequests" + const val PAYSLIPS = "payslips" + const val ANNOUNCEMENTS = "announcements" + const val PROJECTS = "projects" + const val TASKS = "tasks" + + /** Pull order: reference data first so later types can resolve foreign keys. */ + val pullOrder: List = listOf( + BRANCHES, + GEOFENCES, + EMPLOYEES, + SHIFTS, + SHIFT_ASSIGNMENTS, + LEAVE_TYPES, + LEAVE_BALANCES, + LEAVE_REQUESTS, + PUNCHES, + ATTENDANCE_DAYS, + PAYSLIPS, + ANNOUNCEMENTS, + // Projects before tasks: a task names the project it belongs to. + PROJECTS, + TASKS, + ) +} + +object OutboxOpTypes { + const val CREATE = "CREATE" + const val UPDATE = "UPDATE" + const val DELETE = "DELETE" +} diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts new file mode 100644 index 0000000..2d3f58e --- /dev/null +++ b/core/database/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.worktrack.android.library) + alias(libs.plugins.worktrack.android.hilt) + alias(libs.plugins.worktrack.android.room) +} + +android { + namespace = "app.worktrack.core.database" +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.model) + implementation(libs.kotlinx.coroutines.android) +} diff --git a/core/database/schemas/app.worktrack.core.database.WorkTrackDatabase/1.json b/core/database/schemas/app.worktrack.core.database.WorkTrackDatabase/1.json new file mode 100644 index 0000000..dbd94a8 --- /dev/null +++ b/core/database/schemas/app.worktrack.core.database.WorkTrackDatabase/1.json @@ -0,0 +1,1382 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "abb16ac24d4684ad50fd63202a8c6c92", + "entities": [ + { + "tableName": "branches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `name` TEXT NOT NULL, `code` TEXT NOT NULL, `address` TEXT, `latitude` REAL, `longitude` REAL, `radiusMeters` INTEGER, `timezone` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "radiusMeters", + "columnName": "radiusMeters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "geofences", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `branchId` TEXT NOT NULL, `name` TEXT NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `radiusMeters` INTEGER NOT NULL, `active` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "branchId", + "columnName": "branchId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "radiusMeters", + "columnName": "radiusMeters", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_geofences_branchId", + "unique": false, + "columnNames": [ + "branchId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_geofences_branchId` ON `${TABLE_NAME}` (`branchId`)" + }, + { + "name": "index_geofences_active", + "unique": false, + "columnNames": [ + "active" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_geofences_active` ON `${TABLE_NAME}` (`active`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "employees", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `employeeCode` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `email` TEXT NOT NULL, `phone` TEXT, `avatarUrl` TEXT, `branchId` TEXT, `departmentId` TEXT, `positionId` TEXT, `managerId` TEXT, `employmentType` TEXT NOT NULL, `joinDateEpochDay` INTEGER NOT NULL, `status` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "employeeCode", + "columnName": "employeeCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone", + "columnName": "phone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "branchId", + "columnName": "branchId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "departmentId", + "columnName": "departmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "positionId", + "columnName": "positionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "managerId", + "columnName": "managerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "employmentType", + "columnName": "employmentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "joinDateEpochDay", + "columnName": "joinDateEpochDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_employees_branchId", + "unique": false, + "columnNames": [ + "branchId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_employees_branchId` ON `${TABLE_NAME}` (`branchId`)" + }, + { + "name": "index_employees_managerId", + "unique": false, + "columnNames": [ + "managerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_employees_managerId` ON `${TABLE_NAME}` (`managerId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "attendance_punches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `employeeId` TEXT NOT NULL, `punchedAt` INTEGER NOT NULL, `type` TEXT NOT NULL, `method` TEXT NOT NULL, `latitude` REAL, `longitude` REAL, `accuracyMeters` REAL, `geofenceId` TEXT, `insideFence` INTEGER NOT NULL, `note` TEXT, `serverValidated` INTEGER NOT NULL, `invalidReason` TEXT, `syncStatus` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "employeeId", + "columnName": "employeeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "punchedAt", + "columnName": "punchedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "method", + "columnName": "method", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "accuracyMeters", + "columnName": "accuracyMeters", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "geofenceId", + "columnName": "geofenceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "insideFence", + "columnName": "insideFence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverValidated", + "columnName": "serverValidated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invalidReason", + "columnName": "invalidReason", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_attendance_punches_employeeId_punchedAt", + "unique": false, + "columnNames": [ + "employeeId", + "punchedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attendance_punches_employeeId_punchedAt` ON `${TABLE_NAME}` (`employeeId`, `punchedAt`)" + }, + { + "name": "index_attendance_punches_syncStatus", + "unique": false, + "columnNames": [ + "syncStatus" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attendance_punches_syncStatus` ON `${TABLE_NAME}` (`syncStatus`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "attendance_days", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `employeeId` TEXT NOT NULL, `date` INTEGER NOT NULL, `shiftId` TEXT, `firstInAt` INTEGER, `lastOutAt` INTEGER, `workedMinutes` INTEGER NOT NULL, `lateMinutes` INTEGER NOT NULL, `earlyOutMinutes` INTEGER NOT NULL, `overtimeMinutes` INTEGER NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "employeeId", + "columnName": "employeeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shiftId", + "columnName": "shiftId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "firstInAt", + "columnName": "firstInAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastOutAt", + "columnName": "lastOutAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "workedMinutes", + "columnName": "workedMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lateMinutes", + "columnName": "lateMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "earlyOutMinutes", + "columnName": "earlyOutMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "overtimeMinutes", + "columnName": "overtimeMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_attendance_days_employeeId_date", + "unique": true, + "columnNames": [ + "employeeId", + "date" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_attendance_days_employeeId_date` ON `${TABLE_NAME}` (`employeeId`, `date`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "shifts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `name` TEXT NOT NULL, `code` TEXT NOT NULL, `startTimeSecondOfDay` INTEGER NOT NULL, `endTimeSecondOfDay` INTEGER NOT NULL, `breakMinutes` INTEGER NOT NULL, `graceInMinutes` INTEGER NOT NULL, `graceOutMinutes` INTEGER NOT NULL, `isNightShift` INTEGER NOT NULL, `active` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTimeSecondOfDay", + "columnName": "startTimeSecondOfDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTimeSecondOfDay", + "columnName": "endTimeSecondOfDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "breakMinutes", + "columnName": "breakMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "graceInMinutes", + "columnName": "graceInMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "graceOutMinutes", + "columnName": "graceOutMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNightShift", + "columnName": "isNightShift", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "shift_assignments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `employeeId` TEXT NOT NULL, `shiftId` TEXT NOT NULL, `date` INTEGER NOT NULL, `branchId` TEXT, `source` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "employeeId", + "columnName": "employeeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shiftId", + "columnName": "shiftId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "branchId", + "columnName": "branchId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_shift_assignments_employeeId_date", + "unique": true, + "columnNames": [ + "employeeId", + "date" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_shift_assignments_employeeId_date` ON `${TABLE_NAME}` (`employeeId`, `date`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "leave_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `name` TEXT NOT NULL, `code` TEXT NOT NULL, `colorHex` TEXT NOT NULL, `isPaid` INTEGER NOT NULL, `requiresAttachment` INTEGER NOT NULL, `active` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "colorHex", + "columnName": "colorHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPaid", + "columnName": "isPaid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresAttachment", + "columnName": "requiresAttachment", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "leave_balances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `employeeId` TEXT NOT NULL, `leaveTypeId` TEXT NOT NULL, `periodYear` INTEGER NOT NULL, `entitledDays` REAL NOT NULL, `accruedDays` REAL NOT NULL, `usedDays` REAL NOT NULL, `carriedOverDays` REAL NOT NULL, `pendingDays` REAL NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "employeeId", + "columnName": "employeeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "leaveTypeId", + "columnName": "leaveTypeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "periodYear", + "columnName": "periodYear", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entitledDays", + "columnName": "entitledDays", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accruedDays", + "columnName": "accruedDays", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "usedDays", + "columnName": "usedDays", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "carriedOverDays", + "columnName": "carriedOverDays", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "pendingDays", + "columnName": "pendingDays", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_leave_balances_employeeId_leaveTypeId_periodYear", + "unique": true, + "columnNames": [ + "employeeId", + "leaveTypeId", + "periodYear" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_leave_balances_employeeId_leaveTypeId_periodYear` ON `${TABLE_NAME}` (`employeeId`, `leaveTypeId`, `periodYear`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "leave_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `employeeId` TEXT NOT NULL, `employeeName` TEXT, `leaveTypeId` TEXT NOT NULL, `startDate` INTEGER NOT NULL, `endDate` INTEGER NOT NULL, `startHalfDay` INTEGER NOT NULL, `endHalfDay` INTEGER NOT NULL, `days` REAL NOT NULL, `reason` TEXT NOT NULL, `status` TEXT NOT NULL, `currentApproverId` TEXT, `decidedAt` INTEGER, `decisionNote` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `syncStatus` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "employeeId", + "columnName": "employeeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "employeeName", + "columnName": "employeeName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "leaveTypeId", + "columnName": "leaveTypeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startHalfDay", + "columnName": "startHalfDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endHalfDay", + "columnName": "endHalfDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "days", + "columnName": "days", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentApproverId", + "columnName": "currentApproverId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "decidedAt", + "columnName": "decidedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "decisionNote", + "columnName": "decisionNote", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_leave_requests_employeeId_startDate", + "unique": false, + "columnNames": [ + "employeeId", + "startDate" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_leave_requests_employeeId_startDate` ON `${TABLE_NAME}` (`employeeId`, `startDate`)" + }, + { + "name": "index_leave_requests_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_leave_requests_status` ON `${TABLE_NAME}` (`status`)" + }, + { + "name": "index_leave_requests_currentApproverId", + "unique": false, + "columnNames": [ + "currentApproverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_leave_requests_currentApproverId` ON `${TABLE_NAME}` (`currentApproverId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "payslips", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `runId` TEXT NOT NULL, `employeeId` TEXT NOT NULL, `periodYear` INTEGER NOT NULL, `periodMonth` INTEGER NOT NULL, `currency` TEXT NOT NULL, `gross` REAL NOT NULL, `totalDeductions` REAL NOT NULL, `net` REAL NOT NULL, `workedDays` REAL NOT NULL, `paidLeaveDays` REAL NOT NULL, `lopDays` REAL NOT NULL, `overtimeMinutes` INTEGER NOT NULL, `status` TEXT NOT NULL, `pdfUrl` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "runId", + "columnName": "runId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "employeeId", + "columnName": "employeeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "periodYear", + "columnName": "periodYear", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "periodMonth", + "columnName": "periodMonth", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "gross", + "columnName": "gross", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "totalDeductions", + "columnName": "totalDeductions", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "net", + "columnName": "net", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "workedDays", + "columnName": "workedDays", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "paidLeaveDays", + "columnName": "paidLeaveDays", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "lopDays", + "columnName": "lopDays", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "overtimeMinutes", + "columnName": "overtimeMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pdfUrl", + "columnName": "pdfUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_payslips_employeeId_periodYear_periodMonth", + "unique": true, + "columnNames": [ + "employeeId", + "periodYear", + "periodMonth" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_payslips_employeeId_periodYear_periodMonth` ON `${TABLE_NAME}` (`employeeId`, `periodYear`, `periodMonth`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "payslip_lines", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`payslipId` TEXT NOT NULL, `componentCode` TEXT NOT NULL, `componentName` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, PRIMARY KEY(`payslipId`, `componentCode`), FOREIGN KEY(`payslipId`) REFERENCES `payslips`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "payslipId", + "columnName": "payslipId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "componentCode", + "columnName": "componentCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "componentName", + "columnName": "componentName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "payslipId", + "componentCode" + ] + }, + "indices": [ + { + "name": "index_payslip_lines_payslipId", + "unique": false, + "columnNames": [ + "payslipId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_payslip_lines_payslipId` ON `${TABLE_NAME}` (`payslipId`)" + } + ], + "foreignKeys": [ + { + "table": "payslips", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "payslipId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "announcements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `title` TEXT NOT NULL, `body` TEXT NOT NULL, `priority` TEXT NOT NULL, `publishedAt` INTEGER NOT NULL, `expiresAt` INTEGER, `createdByName` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publishedAt", + "columnName": "publishedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdByName", + "columnName": "createdByName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_announcements_publishedAt", + "unique": false, + "columnNames": [ + "publishedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_announcements_publishedAt` ON `${TABLE_NAME}` (`publishedAt`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "outbox_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `opType` TEXT NOT NULL, `resourceType` TEXT NOT NULL, `resourceId` TEXT NOT NULL, `payloadJson` TEXT NOT NULL, `idempotencyKey` TEXT NOT NULL, `attempts` INTEGER NOT NULL, `lastError` TEXT, `state` TEXT NOT NULL, `queuedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opType", + "columnName": "opType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resourceId", + "columnName": "resourceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attempts", + "columnName": "attempts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "queuedAt", + "columnName": "queuedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_outbox_entries_state_queuedAt", + "unique": false, + "columnNames": [ + "state", + "queuedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_outbox_entries_state_queuedAt` ON `${TABLE_NAME}` (`state`, `queuedAt`)" + }, + { + "name": "index_outbox_entries_resourceType", + "unique": false, + "columnNames": [ + "resourceType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_outbox_entries_resourceType` ON `${TABLE_NAME}` (`resourceType`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "sync_cursors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`resourceType` TEXT NOT NULL, `cursor` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`resourceType`))", + "fields": [ + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cursor", + "columnName": "cursor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "resourceType" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'abb16ac24d4684ad50fd63202a8c6c92')" + ] + } +} \ No newline at end of file diff --git a/core/database/schemas/app.worktrack.core.database.WorkTrackDatabase/2.json b/core/database/schemas/app.worktrack.core.database.WorkTrackDatabase/2.json new file mode 100644 index 0000000..7377988 --- /dev/null +++ b/core/database/schemas/app.worktrack.core.database.WorkTrackDatabase/2.json @@ -0,0 +1,1528 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "67d16f62ff0268e0242d2d08b8f597be", + "entities": [ + { + "tableName": "branches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `name` TEXT NOT NULL, `code` TEXT NOT NULL, `address` TEXT, `latitude` REAL, `longitude` REAL, `radiusMeters` INTEGER, `timezone` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "radiusMeters", + "columnName": "radiusMeters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "geofences", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `branchId` TEXT NOT NULL, `name` TEXT NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `radiusMeters` INTEGER NOT NULL, `active` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "branchId", + "columnName": "branchId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "radiusMeters", + "columnName": "radiusMeters", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_geofences_branchId", + "unique": false, + "columnNames": [ + "branchId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_geofences_branchId` ON `${TABLE_NAME}` (`branchId`)" + }, + { + "name": "index_geofences_active", + "unique": false, + "columnNames": [ + "active" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_geofences_active` ON `${TABLE_NAME}` (`active`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "employees", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `employeeCode` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `email` TEXT NOT NULL, `phone` TEXT, `avatarUrl` TEXT, `branchId` TEXT, `departmentId` TEXT, `positionId` TEXT, `managerId` TEXT, `employmentType` TEXT NOT NULL, `joinDateEpochDay` INTEGER NOT NULL, `status` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "employeeCode", + "columnName": "employeeCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone", + "columnName": "phone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "branchId", + "columnName": "branchId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "departmentId", + "columnName": "departmentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "positionId", + "columnName": "positionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "managerId", + "columnName": "managerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "employmentType", + "columnName": "employmentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "joinDateEpochDay", + "columnName": "joinDateEpochDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_employees_branchId", + "unique": false, + "columnNames": [ + "branchId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_employees_branchId` ON `${TABLE_NAME}` (`branchId`)" + }, + { + "name": "index_employees_managerId", + "unique": false, + "columnNames": [ + "managerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_employees_managerId` ON `${TABLE_NAME}` (`managerId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "attendance_punches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `employeeId` TEXT NOT NULL, `punchedAt` INTEGER NOT NULL, `type` TEXT NOT NULL, `method` TEXT NOT NULL, `latitude` REAL, `longitude` REAL, `accuracyMeters` REAL, `geofenceId` TEXT, `insideFence` INTEGER NOT NULL, `note` TEXT, `serverValidated` INTEGER NOT NULL, `invalidReason` TEXT, `syncStatus` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "employeeId", + "columnName": "employeeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "punchedAt", + "columnName": "punchedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "method", + "columnName": "method", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "accuracyMeters", + "columnName": "accuracyMeters", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "geofenceId", + "columnName": "geofenceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "insideFence", + "columnName": "insideFence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverValidated", + "columnName": "serverValidated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invalidReason", + "columnName": "invalidReason", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_attendance_punches_employeeId_punchedAt", + "unique": false, + "columnNames": [ + "employeeId", + "punchedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attendance_punches_employeeId_punchedAt` ON `${TABLE_NAME}` (`employeeId`, `punchedAt`)" + }, + { + "name": "index_attendance_punches_syncStatus", + "unique": false, + "columnNames": [ + "syncStatus" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attendance_punches_syncStatus` ON `${TABLE_NAME}` (`syncStatus`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "attendance_days", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `employeeId` TEXT NOT NULL, `date` INTEGER NOT NULL, `shiftId` TEXT, `firstInAt` INTEGER, `lastOutAt` INTEGER, `workedMinutes` INTEGER NOT NULL, `lateMinutes` INTEGER NOT NULL, `earlyOutMinutes` INTEGER NOT NULL, `overtimeMinutes` INTEGER NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "employeeId", + "columnName": "employeeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shiftId", + "columnName": "shiftId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "firstInAt", + "columnName": "firstInAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastOutAt", + "columnName": "lastOutAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "workedMinutes", + "columnName": "workedMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lateMinutes", + "columnName": "lateMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "earlyOutMinutes", + "columnName": "earlyOutMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "overtimeMinutes", + "columnName": "overtimeMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_attendance_days_employeeId_date", + "unique": true, + "columnNames": [ + "employeeId", + "date" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_attendance_days_employeeId_date` ON `${TABLE_NAME}` (`employeeId`, `date`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "shifts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `name` TEXT NOT NULL, `code` TEXT NOT NULL, `startTimeSecondOfDay` INTEGER NOT NULL, `endTimeSecondOfDay` INTEGER NOT NULL, `breakMinutes` INTEGER NOT NULL, `graceInMinutes` INTEGER NOT NULL, `graceOutMinutes` INTEGER NOT NULL, `isNightShift` INTEGER NOT NULL, `active` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTimeSecondOfDay", + "columnName": "startTimeSecondOfDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTimeSecondOfDay", + "columnName": "endTimeSecondOfDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "breakMinutes", + "columnName": "breakMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "graceInMinutes", + "columnName": "graceInMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "graceOutMinutes", + "columnName": "graceOutMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNightShift", + "columnName": "isNightShift", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "shift_assignments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `employeeId` TEXT NOT NULL, `shiftId` TEXT NOT NULL, `date` INTEGER NOT NULL, `branchId` TEXT, `source` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "employeeId", + "columnName": "employeeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shiftId", + "columnName": "shiftId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "branchId", + "columnName": "branchId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_shift_assignments_employeeId_date", + "unique": true, + "columnNames": [ + "employeeId", + "date" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_shift_assignments_employeeId_date` ON `${TABLE_NAME}` (`employeeId`, `date`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "leave_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `name` TEXT NOT NULL, `code` TEXT NOT NULL, `colorHex` TEXT NOT NULL, `isPaid` INTEGER NOT NULL, `requiresAttachment` INTEGER NOT NULL, `active` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "colorHex", + "columnName": "colorHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPaid", + "columnName": "isPaid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresAttachment", + "columnName": "requiresAttachment", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "leave_balances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `employeeId` TEXT NOT NULL, `leaveTypeId` TEXT NOT NULL, `periodYear` INTEGER NOT NULL, `entitledDays` REAL NOT NULL, `accruedDays` REAL NOT NULL, `usedDays` REAL NOT NULL, `carriedOverDays` REAL NOT NULL, `pendingDays` REAL NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "employeeId", + "columnName": "employeeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "leaveTypeId", + "columnName": "leaveTypeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "periodYear", + "columnName": "periodYear", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entitledDays", + "columnName": "entitledDays", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accruedDays", + "columnName": "accruedDays", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "usedDays", + "columnName": "usedDays", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "carriedOverDays", + "columnName": "carriedOverDays", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "pendingDays", + "columnName": "pendingDays", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_leave_balances_employeeId_leaveTypeId_periodYear", + "unique": true, + "columnNames": [ + "employeeId", + "leaveTypeId", + "periodYear" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_leave_balances_employeeId_leaveTypeId_periodYear` ON `${TABLE_NAME}` (`employeeId`, `leaveTypeId`, `periodYear`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "leave_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `employeeId` TEXT NOT NULL, `employeeName` TEXT, `leaveTypeId` TEXT NOT NULL, `startDate` INTEGER NOT NULL, `endDate` INTEGER NOT NULL, `startHalfDay` INTEGER NOT NULL, `endHalfDay` INTEGER NOT NULL, `days` REAL NOT NULL, `reason` TEXT NOT NULL, `status` TEXT NOT NULL, `currentApproverId` TEXT, `decidedAt` INTEGER, `decisionNote` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `syncStatus` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "employeeId", + "columnName": "employeeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "employeeName", + "columnName": "employeeName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "leaveTypeId", + "columnName": "leaveTypeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startHalfDay", + "columnName": "startHalfDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endHalfDay", + "columnName": "endHalfDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "days", + "columnName": "days", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentApproverId", + "columnName": "currentApproverId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "decidedAt", + "columnName": "decidedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "decisionNote", + "columnName": "decisionNote", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncStatus", + "columnName": "syncStatus", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_leave_requests_employeeId_startDate", + "unique": false, + "columnNames": [ + "employeeId", + "startDate" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_leave_requests_employeeId_startDate` ON `${TABLE_NAME}` (`employeeId`, `startDate`)" + }, + { + "name": "index_leave_requests_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_leave_requests_status` ON `${TABLE_NAME}` (`status`)" + }, + { + "name": "index_leave_requests_currentApproverId", + "unique": false, + "columnNames": [ + "currentApproverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_leave_requests_currentApproverId` ON `${TABLE_NAME}` (`currentApproverId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "payslips", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `runId` TEXT NOT NULL, `employeeId` TEXT NOT NULL, `periodYear` INTEGER NOT NULL, `periodMonth` INTEGER NOT NULL, `currency` TEXT NOT NULL, `gross` REAL NOT NULL, `totalDeductions` REAL NOT NULL, `net` REAL NOT NULL, `workedDays` REAL NOT NULL, `paidLeaveDays` REAL NOT NULL, `lopDays` REAL NOT NULL, `overtimeMinutes` INTEGER NOT NULL, `status` TEXT NOT NULL, `pdfUrl` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "runId", + "columnName": "runId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "employeeId", + "columnName": "employeeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "periodYear", + "columnName": "periodYear", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "periodMonth", + "columnName": "periodMonth", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "gross", + "columnName": "gross", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "totalDeductions", + "columnName": "totalDeductions", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "net", + "columnName": "net", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "workedDays", + "columnName": "workedDays", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "paidLeaveDays", + "columnName": "paidLeaveDays", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "lopDays", + "columnName": "lopDays", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "overtimeMinutes", + "columnName": "overtimeMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pdfUrl", + "columnName": "pdfUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_payslips_employeeId_periodYear_periodMonth", + "unique": true, + "columnNames": [ + "employeeId", + "periodYear", + "periodMonth" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_payslips_employeeId_periodYear_periodMonth` ON `${TABLE_NAME}` (`employeeId`, `periodYear`, `periodMonth`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "payslip_lines", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`payslipId` TEXT NOT NULL, `componentCode` TEXT NOT NULL, `componentName` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, PRIMARY KEY(`payslipId`, `componentCode`), FOREIGN KEY(`payslipId`) REFERENCES `payslips`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "payslipId", + "columnName": "payslipId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "componentCode", + "columnName": "componentCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "componentName", + "columnName": "componentName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "payslipId", + "componentCode" + ] + }, + "indices": [ + { + "name": "index_payslip_lines_payslipId", + "unique": false, + "columnNames": [ + "payslipId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_payslip_lines_payslipId` ON `${TABLE_NAME}` (`payslipId`)" + } + ], + "foreignKeys": [ + { + "table": "payslips", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "payslipId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "announcements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `companyId` TEXT NOT NULL, `title` TEXT NOT NULL, `body` TEXT NOT NULL, `priority` TEXT NOT NULL, `publishedAt` INTEGER NOT NULL, `expiresAt` INTEGER, `createdByName` TEXT, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "companyId", + "columnName": "companyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publishedAt", + "columnName": "publishedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdByName", + "columnName": "createdByName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_announcements_publishedAt", + "unique": false, + "columnNames": [ + "publishedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_announcements_publishedAt` ON `${TABLE_NAME}` (`publishedAt`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "projects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `code` TEXT NOT NULL, `status` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `projectId` TEXT NOT NULL, `projectName` TEXT NOT NULL, `title` TEXT NOT NULL, `detail` TEXT, `location` TEXT, `startDate` INTEGER NOT NULL, `endDate` INTEGER NOT NULL, `status` TEXT NOT NULL, `priority` TEXT NOT NULL, `teamName` TEXT, `assigneeNames` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectName", + "columnName": "projectName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "detail", + "columnName": "detail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "teamName", + "columnName": "teamName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "assigneeNames", + "columnName": "assigneeNames", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tasks_endDate", + "unique": false, + "columnNames": [ + "endDate" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_endDate` ON `${TABLE_NAME}` (`endDate`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "outbox_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `opType` TEXT NOT NULL, `resourceType` TEXT NOT NULL, `resourceId` TEXT NOT NULL, `payloadJson` TEXT NOT NULL, `idempotencyKey` TEXT NOT NULL, `attempts` INTEGER NOT NULL, `lastError` TEXT, `state` TEXT NOT NULL, `queuedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "opType", + "columnName": "opType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resourceId", + "columnName": "resourceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payloadJson", + "columnName": "payloadJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attempts", + "columnName": "attempts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "queuedAt", + "columnName": "queuedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_outbox_entries_state_queuedAt", + "unique": false, + "columnNames": [ + "state", + "queuedAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_outbox_entries_state_queuedAt` ON `${TABLE_NAME}` (`state`, `queuedAt`)" + }, + { + "name": "index_outbox_entries_resourceType", + "unique": false, + "columnNames": [ + "resourceType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_outbox_entries_resourceType` ON `${TABLE_NAME}` (`resourceType`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "sync_cursors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`resourceType` TEXT NOT NULL, `cursor` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`resourceType`))", + "fields": [ + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cursor", + "columnName": "cursor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "resourceType" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '67d16f62ff0268e0242d2d08b8f597be')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/DatabaseCleaner.kt b/core/database/src/main/kotlin/app/worktrack/core/database/DatabaseCleaner.kt new file mode 100644 index 0000000..a7ad8c7 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/DatabaseCleaner.kt @@ -0,0 +1,24 @@ +package app.worktrack.core.database + +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Wipes all local tenant data on sign-out so nothing survives on shared devices. + * + * This class exists so that downstream modules (e.g. :core:data) can trigger a + * full wipe WITHOUT depending on Room: they inject DatabaseCleaner — whose only + * supertype is Any — instead of WorkTrackDatabase, whose RoomDatabase supertype + * would otherwise need to be on their compile classpath. + */ +@Singleton +class DatabaseCleaner @Inject constructor( + private val database: WorkTrackDatabase, +) { + /** clearAllTables() is blocking/@WorkerThread; run it off the main thread. */ + suspend fun clearAllTenantData() = withContext(Dispatchers.IO) { + database.clearAllTables() + } +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/WorkTrackDatabase.kt b/core/database/src/main/kotlin/app/worktrack/core/database/WorkTrackDatabase.kt new file mode 100644 index 0000000..0ead150 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/WorkTrackDatabase.kt @@ -0,0 +1,92 @@ +package app.worktrack.core.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import app.worktrack.core.database.converter.Converters +import app.worktrack.core.database.dao.AnnouncementDao +import app.worktrack.core.database.dao.AttendanceDao +import app.worktrack.core.database.dao.LeaveDao +import app.worktrack.core.database.dao.OrgDao +import app.worktrack.core.database.dao.OutboxDao +import app.worktrack.core.database.dao.PayslipDao +import app.worktrack.core.database.dao.ShiftDao +import app.worktrack.core.database.dao.SyncCursorDao +import app.worktrack.core.database.dao.WorkDao +import app.worktrack.core.database.entity.AnnouncementEntity +import app.worktrack.core.database.entity.AttendanceDayEntity +import app.worktrack.core.database.entity.AttendancePunchEntity +import app.worktrack.core.database.entity.BranchEntity +import app.worktrack.core.database.entity.EmployeeEntity +import app.worktrack.core.database.entity.GeofenceEntity +import app.worktrack.core.database.entity.LeaveBalanceEntity +import app.worktrack.core.database.entity.LeaveRequestEntity +import app.worktrack.core.database.entity.LeaveTypeEntity +import app.worktrack.core.database.entity.OutboxEntryEntity +import app.worktrack.core.database.entity.PayslipEntity +import app.worktrack.core.database.entity.PayslipLineEntity +import app.worktrack.core.database.entity.ShiftAssignmentEntity +import app.worktrack.core.database.entity.ShiftEntity +import app.worktrack.core.database.entity.ProjectEntity +import app.worktrack.core.database.entity.SyncCursorEntity +import app.worktrack.core.database.entity.TaskEntity + +@Database( + entities = [ + BranchEntity::class, + GeofenceEntity::class, + EmployeeEntity::class, + AttendancePunchEntity::class, + AttendanceDayEntity::class, + ShiftEntity::class, + ShiftAssignmentEntity::class, + LeaveTypeEntity::class, + LeaveBalanceEntity::class, + LeaveRequestEntity::class, + PayslipEntity::class, + PayslipLineEntity::class, + AnnouncementEntity::class, + ProjectEntity::class, + TaskEntity::class, + OutboxEntryEntity::class, + SyncCursorEntity::class, + ], + version = 2, + exportSchema = true, +) +@TypeConverters(Converters::class) +abstract class WorkTrackDatabase : RoomDatabase() { + abstract fun orgDao(): OrgDao + abstract fun attendanceDao(): AttendanceDao + abstract fun shiftDao(): ShiftDao + abstract fun leaveDao(): LeaveDao + abstract fun payslipDao(): PayslipDao + abstract fun announcementDao(): AnnouncementDao + abstract fun workDao(): WorkDao + abstract fun outboxDao(): OutboxDao + abstract fun syncCursorDao(): SyncCursorDao +} + +/** + * v1 -> v2: work assignment (projects and tasks). + * + * Additive only — two new tables, nothing existing is touched — so a phone + * upgrading in the field keeps its punches, its pending outbox and its + * payslips. Written by hand rather than left to a destructive fallback: an + * employee whose punch has not synced yet must not lose it to an app update. + */ +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + // Copied verbatim from schemas/…/2.json so the tables Room validates on + // open are the tables this creates, character for character. + db.execSQL( + "CREATE TABLE IF NOT EXISTS `projects` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `code` TEXT NOT NULL, `status` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `tasks` (`id` TEXT NOT NULL, `projectId` TEXT NOT NULL, `projectName` TEXT NOT NULL, `title` TEXT NOT NULL, `detail` TEXT, `location` TEXT, `startDate` INTEGER NOT NULL, `endDate` INTEGER NOT NULL, `status` TEXT NOT NULL, `priority` TEXT NOT NULL, `teamName` TEXT, `assigneeNames` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_tasks_endDate` ON `tasks` (`endDate`)") + } +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/converter/Converters.kt b/core/database/src/main/kotlin/app/worktrack/core/database/converter/Converters.kt new file mode 100644 index 0000000..b556f74 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/converter/Converters.kt @@ -0,0 +1,50 @@ +package app.worktrack.core.database.converter + +import androidx.room.TypeConverter +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime + +/** + * java.time storage strategy: + * - Instant -> epoch millis (Long) — range queries stay index-friendly + * - LocalDate -> epoch day (Long) — timezone-proof calendar dates + * - LocalTime -> second of day (Int) — shift boundaries + * - List -> one string joined on U+001F (the ASCII unit separator), + * a character that cannot occur in a name typed into the portal + * Enums are persisted by name via Room's built-in enum support. + */ +class Converters { + + @TypeConverter + fun instantToLong(value: Instant?): Long? = value?.toEpochMilli() + + @TypeConverter + fun longToInstant(value: Long?): Instant? = value?.let(Instant::ofEpochMilli) + + @TypeConverter + fun localDateToLong(value: LocalDate?): Long? = value?.toEpochDay() + + @TypeConverter + fun longToLocalDate(value: Long?): LocalDate? = value?.let(LocalDate::ofEpochDay) + + @TypeConverter + fun localTimeToInt(value: LocalTime?): Int? = value?.toSecondOfDay() + + @TypeConverter + // ofSecondOfDay takes a Long; Kotlin won't widen Int automatically. + fun intToLocalTime(value: Int?): LocalTime? = value?.let { LocalTime.ofSecondOfDay(it.toLong()) } + + @TypeConverter + fun stringListToString(value: List?): String? = value?.joinToString(SEPARATOR) + + @TypeConverter + // "".split(sep) yields [""], not [] — an empty list must round-trip empty + // or a solo task would claim to have one nameless colleague on it. + fun stringToStringList(value: String?): List? = + value?.let { if (it.isEmpty()) emptyList() else it.split(SEPARATOR) } + + private companion object { + const val SEPARATOR = "\u001F" + } +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/AnnouncementDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/AnnouncementDao.kt new file mode 100644 index 0000000..a45ec83 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/AnnouncementDao.kt @@ -0,0 +1,28 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import app.worktrack.core.database.entity.AnnouncementEntity +import java.time.Instant +import kotlinx.coroutines.flow.Flow + +@Dao +interface AnnouncementDao { + + @Upsert + suspend fun upsertAnnouncements(announcements: List) + + @Query( + """ + SELECT * FROM announcements + WHERE publishedAt <= :now AND (expiresAt IS NULL OR expiresAt > :now) + ORDER BY publishedAt DESC + LIMIT 100 + """, + ) + fun observeActive(now: Instant): Flow> + + @Query("DELETE FROM announcements") + suspend fun clearAnnouncements() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/AttendanceDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/AttendanceDao.kt new file mode 100644 index 0000000..69bbef7 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/AttendanceDao.kt @@ -0,0 +1,77 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Upsert +import app.worktrack.core.database.entity.AttendanceDayEntity +import app.worktrack.core.database.entity.AttendancePunchEntity +import app.worktrack.core.model.SyncStatus +import java.time.Instant +import java.time.LocalDate +import kotlinx.coroutines.flow.Flow + +@Dao +interface AttendanceDao { + + // Punches are append-only: IGNORE keeps the first write (idempotent replays). + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertPunch(punch: AttendancePunchEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertPunches(punches: List) + + @Query( + """ + SELECT * FROM attendance_punches + WHERE employeeId = :employeeId AND punchedAt BETWEEN :from AND :to + ORDER BY punchedAt ASC + """, + ) + fun observePunchesBetween( + employeeId: String, + from: Instant, + to: Instant, + ): Flow> + + @Query( + """ + UPDATE attendance_punches + SET syncStatus = :syncStatus, serverValidated = :serverValidated, + invalidReason = :invalidReason + WHERE id = :id + """, + ) + suspend fun updatePunchSyncResult( + id: String, + syncStatus: SyncStatus, + serverValidated: Boolean, + invalidReason: String?, + ) + + @Query("DELETE FROM attendance_punches WHERE punchedAt < :cutoff AND syncStatus = 'SYNCED'") + suspend fun prunePunchesBefore(cutoff: Instant) + + @Upsert + suspend fun upsertDays(days: List) + + @Query( + """ + SELECT * FROM attendance_days + WHERE employeeId = :employeeId AND date BETWEEN :from AND :to + ORDER BY date DESC + """, + ) + fun observeDaysBetween( + employeeId: String, + from: LocalDate, + to: LocalDate, + ): Flow> + + @Query("DELETE FROM attendance_punches") + suspend fun clearPunches() + + @Query("DELETE FROM attendance_days") + suspend fun clearDays() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/LeaveDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/LeaveDao.kt new file mode 100644 index 0000000..247d5cc --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/LeaveDao.kt @@ -0,0 +1,76 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import app.worktrack.core.database.entity.LeaveBalanceEntity +import app.worktrack.core.database.entity.LeaveRequestEntity +import app.worktrack.core.database.entity.LeaveTypeEntity +import app.worktrack.core.model.LeaveStatus +import app.worktrack.core.model.SyncStatus +import java.time.Instant +import kotlinx.coroutines.flow.Flow + +@Dao +interface LeaveDao { + + @Upsert + suspend fun upsertTypes(types: List) + + @Query("SELECT * FROM leave_types WHERE active = 1 ORDER BY name") + fun observeActiveTypes(): Flow> + + @Upsert + suspend fun upsertBalances(balances: List) + + @Query("SELECT * FROM leave_balances WHERE employeeId = :employeeId AND periodYear = :year") + fun observeBalances(employeeId: String, year: Int): Flow> + + @Upsert + suspend fun upsertRequests(requests: List) + + @Query( + """ + SELECT * FROM leave_requests + WHERE employeeId = :employeeId + ORDER BY startDate DESC + LIMIT 200 + """, + ) + fun observeMyRequests(employeeId: String): Flow> + + @Query( + """ + SELECT * FROM leave_requests + WHERE currentApproverId = :approverId AND status = 'PENDING' + ORDER BY startDate ASC + """, + ) + fun observePendingApprovals(approverId: String): Flow> + + @Query("SELECT * FROM leave_requests WHERE id = :id") + suspend fun requestById(id: String): LeaveRequestEntity? + + @Query( + """ + UPDATE leave_requests + SET status = :status, syncStatus = :syncStatus, updatedAt = :updatedAt + WHERE id = :id + """, + ) + suspend fun updateRequestStatus( + id: String, + status: LeaveStatus, + syncStatus: SyncStatus, + updatedAt: Instant, + ) + + @Query("DELETE FROM leave_types") + suspend fun clearTypes() + + @Query("DELETE FROM leave_balances") + suspend fun clearBalances() + + @Query("DELETE FROM leave_requests") + suspend fun clearRequests() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/OrgDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/OrgDao.kt new file mode 100644 index 0000000..101a136 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/OrgDao.kt @@ -0,0 +1,40 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import app.worktrack.core.database.entity.BranchEntity +import app.worktrack.core.database.entity.EmployeeEntity +import app.worktrack.core.database.entity.GeofenceEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface OrgDao { + + @Upsert + suspend fun upsertBranches(branches: List) + + @Query("SELECT * FROM branches ORDER BY name") + fun observeBranches(): Flow> + + @Upsert + suspend fun upsertGeofences(geofences: List) + + @Query("SELECT * FROM geofences WHERE active = 1") + fun observeActiveGeofences(): Flow> + + @Upsert + suspend fun upsertEmployees(employees: List) + + @Query("SELECT * FROM employees WHERE id = :employeeId") + fun observeEmployee(employeeId: String): Flow + + @Query("DELETE FROM branches") + suspend fun clearBranches() + + @Query("DELETE FROM geofences") + suspend fun clearGeofences() + + @Query("DELETE FROM employees") + suspend fun clearEmployees() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/OutboxDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/OutboxDao.kt new file mode 100644 index 0000000..6c53d0b --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/OutboxDao.kt @@ -0,0 +1,46 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import app.worktrack.core.database.entity.OutboxEntryEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface OutboxDao { + + @Insert + suspend fun insert(entry: OutboxEntryEntity) + + /** Oldest-first pending work; FIFO ordering preserves causal order per resource. */ + @Query("SELECT * FROM outbox_entries WHERE state = 'PENDING' ORDER BY queuedAt ASC LIMIT :limit") + suspend fun nextPending(limit: Int): List + + @Query("UPDATE outbox_entries SET state = 'IN_FLIGHT' WHERE id IN (:ids)") + suspend fun markInFlight(ids: List) + + @Query("DELETE FROM outbox_entries WHERE id = :id") + suspend fun delete(id: String) + + @Query( + """ + UPDATE outbox_entries + SET state = :state, attempts = attempts + 1, lastError = :error + WHERE id = :id + """, + ) + suspend fun markAttemptFailed(id: String, state: String, error: String?) + + /** Recovers entries stranded IN_FLIGHT by a process death mid-sync. */ + @Query("UPDATE outbox_entries SET state = 'PENDING' WHERE state = 'IN_FLIGHT'") + suspend fun requeueInFlight() + + @Query("SELECT COUNT(*) FROM outbox_entries WHERE state IN ('PENDING', 'IN_FLIGHT')") + fun observePendingCount(): Flow + + @Query("SELECT COUNT(*) FROM outbox_entries WHERE state = 'FAILED'") + fun observeFailedCount(): Flow + + @Query("DELETE FROM outbox_entries") + suspend fun clear() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/PayslipDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/PayslipDao.kt new file mode 100644 index 0000000..5d7d453 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/PayslipDao.kt @@ -0,0 +1,49 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Upsert +import app.worktrack.core.database.entity.PayslipEntity +import app.worktrack.core.database.entity.PayslipLineEntity +import app.worktrack.core.database.entity.PayslipWithLines +import kotlinx.coroutines.flow.Flow + +@Dao +interface PayslipDao { + + @Transaction + @Query( + """ + SELECT * FROM payslips + WHERE employeeId = :employeeId AND periodYear = :year + ORDER BY periodMonth DESC + """, + ) + fun observePayslips(employeeId: String, year: Int): Flow> + + @Transaction + @Query("SELECT * FROM payslips WHERE id = :payslipId") + fun observePayslip(payslipId: String): Flow + + @Upsert + suspend fun upsertPayslips(payslips: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLines(lines: List) + + @Query("DELETE FROM payslip_lines WHERE payslipId = :payslipId") + suspend fun deleteLines(payslipId: String) + + @Transaction + suspend fun replacePayslip(payslip: PayslipEntity, lines: List) { + upsertPayslips(listOf(payslip)) + deleteLines(payslip.id) + insertLines(lines) + } + + @Query("DELETE FROM payslips") + suspend fun clearPayslips() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/ShiftDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/ShiftDao.kt new file mode 100644 index 0000000..3d108f4 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/ShiftDao.kt @@ -0,0 +1,38 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import app.worktrack.core.database.entity.ShiftAssignmentEntity +import app.worktrack.core.database.entity.ShiftEntity +import java.time.LocalDate +import kotlinx.coroutines.flow.Flow + +@Dao +interface ShiftDao { + + @Upsert + suspend fun upsertShifts(shifts: List) + + @Upsert + suspend fun upsertAssignments(assignments: List) + + @Query("SELECT * FROM shifts WHERE id = :shiftId") + suspend fun shiftById(shiftId: String): ShiftEntity? + + @Query( + """ + SELECT s.* FROM shifts s + INNER JOIN shift_assignments a ON a.shiftId = s.id + WHERE a.employeeId = :employeeId AND a.date = :date + LIMIT 1 + """, + ) + fun observeShiftForDate(employeeId: String, date: LocalDate): Flow + + @Query("DELETE FROM shifts") + suspend fun clearShifts() + + @Query("DELETE FROM shift_assignments") + suspend fun clearAssignments() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/SyncCursorDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/SyncCursorDao.kt new file mode 100644 index 0000000..0aa7c15 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/SyncCursorDao.kt @@ -0,0 +1,19 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import app.worktrack.core.database.entity.SyncCursorEntity + +@Dao +interface SyncCursorDao { + + @Query("SELECT * FROM sync_cursors WHERE resourceType = :resourceType") + suspend fun cursor(resourceType: String): SyncCursorEntity? + + @Upsert + suspend fun upsert(cursor: SyncCursorEntity) + + @Query("DELETE FROM sync_cursors") + suspend fun clear() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/dao/WorkDao.kt b/core/database/src/main/kotlin/app/worktrack/core/database/dao/WorkDao.kt new file mode 100644 index 0000000..0ddb530 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/dao/WorkDao.kt @@ -0,0 +1,54 @@ +package app.worktrack.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import app.worktrack.core.database.entity.ProjectEntity +import app.worktrack.core.database.entity.TaskEntity +import java.time.LocalDate +import kotlinx.coroutines.flow.Flow + +@Dao +interface WorkDao { + + @Upsert + suspend fun upsertProjects(projects: List) + + @Upsert + suspend fun upsertTasks(tasks: List) + + /** + * Every task live on any day in [from]..[to]. + * + * A task overlaps the window when it has not finished before it starts and + * did not start after it ends — not when its own start falls inside it, + * which would hide the multi-day job an employee is in the middle of. + */ + @Query( + """ + SELECT * FROM tasks + WHERE endDate >= :from AND startDate <= :to + ORDER BY startDate ASC, title ASC + """, + ) + fun observeBetween(from: LocalDate, to: LocalDate): Flow> + + @Query("SELECT * FROM tasks WHERE id = :id") + suspend fun taskById(id: String): TaskEntity? + + @Query("UPDATE tasks SET status = :status, updatedAt = :updatedAt WHERE id = :id") + suspend fun updateStatus(id: String, status: String, updatedAt: java.time.Instant) + + /** + * Work that finished before [before] is dropped. Without this the table + * grows for the life of the install, on phones that have little room. + */ + @Query("DELETE FROM tasks WHERE endDate < :before") + suspend fun pruneTasksBefore(before: LocalDate) + + @Query("DELETE FROM tasks") + suspend fun clearTasks() + + @Query("DELETE FROM projects") + suspend fun clearProjects() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/di/DatabaseModule.kt b/core/database/src/main/kotlin/app/worktrack/core/database/di/DatabaseModule.kt new file mode 100644 index 0000000..c6c7652 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/di/DatabaseModule.kt @@ -0,0 +1,37 @@ +package app.worktrack.core.database.di + +import android.content.Context +import androidx.room.Room +import app.worktrack.core.database.MIGRATION_1_2 +import app.worktrack.core.database.WorkTrackDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): WorkTrackDatabase = + Room.databaseBuilder(context, WorkTrackDatabase::class.java, "worktrack.db") + // Destructive fallback stays OFF: schema changes require an explicit + // Migration or a failed build, never silent data loss. A phone can be + // carrying punches that have not reached the server yet. + .addMigrations(MIGRATION_1_2) + .build() + + @Provides fun provideOrgDao(db: WorkTrackDatabase) = db.orgDao() + @Provides fun provideAttendanceDao(db: WorkTrackDatabase) = db.attendanceDao() + @Provides fun provideShiftDao(db: WorkTrackDatabase) = db.shiftDao() + @Provides fun provideLeaveDao(db: WorkTrackDatabase) = db.leaveDao() + @Provides fun providePayslipDao(db: WorkTrackDatabase) = db.payslipDao() + @Provides fun provideAnnouncementDao(db: WorkTrackDatabase) = db.announcementDao() + @Provides fun provideWorkDao(db: WorkTrackDatabase) = db.workDao() + @Provides fun provideOutboxDao(db: WorkTrackDatabase) = db.outboxDao() + @Provides fun provideSyncCursorDao(db: WorkTrackDatabase) = db.syncCursorDao() +} diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/entity/AttendanceEntities.kt b/core/database/src/main/kotlin/app/worktrack/core/database/entity/AttendanceEntities.kt new file mode 100644 index 0000000..1d96b7c --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/entity/AttendanceEntities.kt @@ -0,0 +1,86 @@ +package app.worktrack.core.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import app.worktrack.core.model.PunchMethod +import app.worktrack.core.model.PunchType +import app.worktrack.core.model.SyncStatus +import java.time.Instant +import java.time.LocalDate + +/** Append-only local mirror of clock events. 90-day retention window on device. */ +@Entity( + tableName = "attendance_punches", + indices = [ + Index(value = ["employeeId", "punchedAt"]), + Index("syncStatus"), + ], +) +data class AttendancePunchEntity( + @PrimaryKey val id: String, + val companyId: String, + val employeeId: String, + val punchedAt: Instant, + val type: PunchType, + val method: PunchMethod, + val latitude: Double?, + val longitude: Double?, + val accuracyMeters: Float?, + val geofenceId: String?, + val insideFence: Boolean, + val note: String?, + val serverValidated: Boolean, + val invalidReason: String?, + val syncStatus: SyncStatus, +) + +/** Server-computed daily summary; read-only on the client. */ +@Entity( + tableName = "attendance_days", + indices = [Index(value = ["employeeId", "date"], unique = true)], +) +data class AttendanceDayEntity( + @PrimaryKey val id: String, + val employeeId: String, + val date: LocalDate, + val shiftId: String?, + val firstInAt: Instant?, + val lastOutAt: Instant?, + val workedMinutes: Int, + val lateMinutes: Int, + val earlyOutMinutes: Int, + val overtimeMinutes: Int, + val status: String, +) + +@Entity(tableName = "shifts") +data class ShiftEntity( + @PrimaryKey val id: String, + val companyId: String, + val name: String, + val code: String, + val startTimeSecondOfDay: Int, + val endTimeSecondOfDay: Int, + val breakMinutes: Int, + val graceInMinutes: Int, + val graceOutMinutes: Int, + val isNightShift: Boolean, + val active: Boolean, + val updatedAt: Instant, +) + +@Entity( + tableName = "shift_assignments", + indices = [Index(value = ["employeeId", "date"], unique = true)], +) +data class ShiftAssignmentEntity( + @PrimaryKey val id: String, + val companyId: String, + val employeeId: String, + val shiftId: String, + val date: LocalDate, + val branchId: String?, + val source: String, + val updatedAt: Instant, +) diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/entity/LeaveEntities.kt b/core/database/src/main/kotlin/app/worktrack/core/database/entity/LeaveEntities.kt new file mode 100644 index 0000000..ff9aa3e --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/entity/LeaveEntities.kt @@ -0,0 +1,68 @@ +package app.worktrack.core.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import app.worktrack.core.model.LeaveStatus +import app.worktrack.core.model.SyncStatus +import java.time.Instant +import java.time.LocalDate + +@Entity(tableName = "leave_types") +data class LeaveTypeEntity( + @PrimaryKey val id: String, + val companyId: String, + val name: String, + val code: String, + val colorHex: String, + val isPaid: Boolean, + val requiresAttachment: Boolean, + val active: Boolean, + val updatedAt: Instant, +) + +@Entity( + tableName = "leave_balances", + indices = [Index(value = ["employeeId", "leaveTypeId", "periodYear"], unique = true)], +) +data class LeaveBalanceEntity( + @PrimaryKey val id: String, + val employeeId: String, + val leaveTypeId: String, + val periodYear: Int, + val entitledDays: Double, + val accruedDays: Double, + val usedDays: Double, + val carriedOverDays: Double, + val pendingDays: Double, + val updatedAt: Instant, +) + +@Entity( + tableName = "leave_requests", + indices = [ + Index(value = ["employeeId", "startDate"]), + Index("status"), + Index("currentApproverId"), + ], +) +data class LeaveRequestEntity( + @PrimaryKey val id: String, + val companyId: String, + val employeeId: String, + val employeeName: String?, + val leaveTypeId: String, + val startDate: LocalDate, + val endDate: LocalDate, + val startHalfDay: Boolean, + val endHalfDay: Boolean, + val days: Double, + val reason: String, + val status: LeaveStatus, + val currentApproverId: String?, + val decidedAt: Instant?, + val decisionNote: String?, + val createdAt: Instant, + val updatedAt: Instant, + val syncStatus: SyncStatus, +) diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/entity/OrgEntities.kt b/core/database/src/main/kotlin/app/worktrack/core/database/entity/OrgEntities.kt new file mode 100644 index 0000000..8289d20 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/entity/OrgEntities.kt @@ -0,0 +1,59 @@ +package app.worktrack.core.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import java.time.Instant + +@Entity(tableName = "branches") +data class BranchEntity( + @PrimaryKey val id: String, + val companyId: String, + val name: String, + val code: String, + val address: String?, + val latitude: Double?, + val longitude: Double?, + val radiusMeters: Int?, + val timezone: String, + val updatedAt: Instant, +) + +@Entity( + tableName = "geofences", + indices = [Index("branchId"), Index("active")], +) +data class GeofenceEntity( + @PrimaryKey val id: String, + val companyId: String, + val branchId: String, + val name: String, + val latitude: Double, + val longitude: Double, + val radiusMeters: Int, + val active: Boolean, + val updatedAt: Instant, +) + +@Entity( + tableName = "employees", + indices = [Index("branchId"), Index("managerId")], +) +data class EmployeeEntity( + @PrimaryKey val id: String, + val companyId: String, + val employeeCode: String, + val firstName: String, + val lastName: String, + val email: String, + val phone: String?, + val avatarUrl: String?, + val branchId: String?, + val departmentId: String?, + val positionId: String?, + val managerId: String?, + val employmentType: String, + val joinDateEpochDay: Long, + val status: String, + val updatedAt: Instant, +) diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/entity/PayrollEntities.kt b/core/database/src/main/kotlin/app/worktrack/core/database/entity/PayrollEntities.kt new file mode 100644 index 0000000..b797a2c --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/entity/PayrollEntities.kt @@ -0,0 +1,60 @@ +package app.worktrack.core.database.entity + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.Relation +import java.time.Instant + +@Entity( + tableName = "payslips", + indices = [Index(value = ["employeeId", "periodYear", "periodMonth"], unique = true)], +) +data class PayslipEntity( + @PrimaryKey val id: String, + val companyId: String, + val runId: String, + val employeeId: String, + val periodYear: Int, + val periodMonth: Int, + val currency: String, + val gross: Double, + val totalDeductions: Double, + val net: Double, + val workedDays: Double, + val paidLeaveDays: Double, + val lopDays: Double, + val overtimeMinutes: Int, + val status: String, + val pdfUrl: String?, + val updatedAt: Instant, +) + +@Entity( + tableName = "payslip_lines", + primaryKeys = ["payslipId", "componentCode"], + foreignKeys = [ + ForeignKey( + entity = PayslipEntity::class, + parentColumns = ["id"], + childColumns = ["payslipId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("payslipId")], +) +data class PayslipLineEntity( + val payslipId: String, + val componentCode: String, + val componentName: String, + val type: String, + val amount: Double, +) + +data class PayslipWithLines( + @Embedded val payslip: PayslipEntity, + @Relation(parentColumn = "id", entityColumn = "payslipId") + val lines: List, +) diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/entity/PlatformEntities.kt b/core/database/src/main/kotlin/app/worktrack/core/database/entity/PlatformEntities.kt new file mode 100644 index 0000000..7098545 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/entity/PlatformEntities.kt @@ -0,0 +1,51 @@ +package app.worktrack.core.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import java.time.Instant + +@Entity( + tableName = "announcements", + indices = [Index("publishedAt")], +) +data class AnnouncementEntity( + @PrimaryKey val id: String, + val companyId: String, + val title: String, + val body: String, + val priority: String, + val publishedAt: Instant, + val expiresAt: Instant?, + val createdByName: String?, + val updatedAt: Instant, +) + +/** + * Pending mutation queue (the client half of the outbox pattern). + * Drained FIFO per resource type by the sync engine; rows are deleted on ack. + */ +@Entity( + tableName = "outbox_entries", + indices = [Index(value = ["state", "queuedAt"]), Index("resourceType")], +) +data class OutboxEntryEntity( + @PrimaryKey val id: String, + val opType: String, + val resourceType: String, + val resourceId: String, + val payloadJson: String, + val idempotencyKey: String, + val attempts: Int, + val lastError: String?, + val state: String, + val queuedAt: Instant, +) + +/** Per-resource-type delta cursor for incremental pull. */ +@Entity(tableName = "sync_cursors") +data class SyncCursorEntity( + @PrimaryKey val resourceType: String, + val cursor: String, + val lastSyncedAt: Instant, +) diff --git a/core/database/src/main/kotlin/app/worktrack/core/database/entity/WorkEntities.kt b/core/database/src/main/kotlin/app/worktrack/core/database/entity/WorkEntities.kt new file mode 100644 index 0000000..46bd592 --- /dev/null +++ b/core/database/src/main/kotlin/app/worktrack/core/database/entity/WorkEntities.kt @@ -0,0 +1,43 @@ +package app.worktrack.core.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import java.time.Instant +import java.time.LocalDate + +/** Reference data: what the company is building. */ +@Entity(tableName = "projects") +data class ProjectEntity( + @PrimaryKey val id: String, + val name: String, + val code: String, + val status: String, + val updatedAt: Instant, +) + +/** + * One piece of work assigned to this employee. + * + * Only the caller's own tasks are ever replicated here — the delta pull is + * scoped by assignee — so there is no employee column to filter on. The names + * of the others on the job travel with the row for the same reason. + * + * Indexed on endDate because every read is "what is live on or after day X". + */ +@Entity(tableName = "tasks", indices = [Index("endDate")]) +data class TaskEntity( + @PrimaryKey val id: String, + val projectId: String, + val projectName: String, + val title: String, + val detail: String?, + val location: String?, + val startDate: LocalDate, + val endDate: LocalDate, + val status: String, + val priority: String, + val teamName: String?, + val assigneeNames: List, + val updatedAt: Instant, +) diff --git a/core/database/src/test/kotlin/app/worktrack/core/database/converter/ConvertersTest.kt b/core/database/src/test/kotlin/app/worktrack/core/database/converter/ConvertersTest.kt new file mode 100644 index 0000000..e2ba953 --- /dev/null +++ b/core/database/src/test/kotlin/app/worktrack/core/database/converter/ConvertersTest.kt @@ -0,0 +1,52 @@ +package app.worktrack.core.database.converter + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The string-list converter, which stores the names of the other people on a + * task. + * + * The empty case is the one that matters: "".split(sep) yields [""], so a + * careless round trip turns a solo job into one with a nameless colleague on + * it, and the app then labels it team work. + */ +class ConvertersTest { + + private val converters = Converters() + + @Test + fun `round-trips a list of names`() { + val names = listOf("Ali Rahimi", "Omar Nazari", "فاطمه سادات") + val stored = converters.stringListToString(names) + assertEquals(names, converters.stringToStringList(stored)) + } + + @Test + fun `an empty list comes back empty, not as one blank name`() { + val stored = converters.stringListToString(emptyList()) + assertEquals(emptyList(), converters.stringToStringList(stored)) + } + + @Test + fun `one name stays one name`() { + val stored = converters.stringListToString(listOf("Ali Rahimi")) + assertEquals(listOf("Ali Rahimi"), converters.stringToStringList(stored)) + } + + @Test + fun `null survives as null`() { + assertNull(converters.stringListToString(null)) + assertNull(converters.stringToStringList(null)) + } + + @Test + fun `a name containing spaces and commas is not split`() { + // The separator is U+001F precisely so ordinary punctuation in a name + // typed into the portal cannot break the row apart. + val names = listOf("Sadat, Fatima", "Ali Rahimi") + val stored = converters.stringListToString(names) + assertEquals(names, converters.stringToStringList(stored)) + } +} diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts new file mode 100644 index 0000000..ac6cd1d --- /dev/null +++ b/core/datastore/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.worktrack.android.library) + alias(libs.plugins.worktrack.android.hilt) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "app.worktrack.core.datastore" +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.model) + implementation(libs.androidx.datastore.preferences) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) +} diff --git a/core/datastore/src/main/kotlin/app/worktrack/core/datastore/DeviceIdStore.kt b/core/datastore/src/main/kotlin/app/worktrack/core/datastore/DeviceIdStore.kt new file mode 100644 index 0000000..669025f --- /dev/null +++ b/core/datastore/src/main/kotlin/app/worktrack/core/datastore/DeviceIdStore.kt @@ -0,0 +1,53 @@ +package app.worktrack.core.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * A stable identifier for this installation, used to claim a licence seat. + * + * Generated once and kept for the life of the install. It is deliberately NOT + * derived from hardware identifiers: ANDROID_ID and friends need permissions, + * change under work profiles, and are personal data we have no reason to hold. + * A random id tied to the app's own storage is enough to count devices, which + * is all the licence needs. + * + * Clearing the app's data yields a new id and therefore a new seat, so an + * administrator can revoke the stale one from the portal. + */ +@Singleton +class DeviceIdStore @Inject constructor( + private val dataStore: DataStore, +) { + private val key = stringPreferencesKey("device_id") + + // Two callers racing on first launch must not mint two different ids. + private val mutex = Mutex() + + @Volatile + private var cached: String? = null + + suspend fun deviceId(): String { + cached?.let { return it } + return mutex.withLock { + cached?.let { return it } + val existing = dataStore.data.first()[key] + val id = existing ?: newId().also { fresh -> + dataStore.edit { it[key] = fresh } + } + cached = id + id + } + } + + /** Matches the server's accepted shape: A–Z, 0–9, underscore and dash. */ + private fun newId(): String = "and-" + UUID.randomUUID().toString().replace("-", "") +} diff --git a/core/datastore/src/main/kotlin/app/worktrack/core/datastore/SessionStore.kt b/core/datastore/src/main/kotlin/app/worktrack/core/datastore/SessionStore.kt new file mode 100644 index 0000000..58c372b --- /dev/null +++ b/core/datastore/src/main/kotlin/app/worktrack/core/datastore/SessionStore.kt @@ -0,0 +1,136 @@ +package app.worktrack.core.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import app.worktrack.core.model.CompanyFeatures +import app.worktrack.core.model.RoleCode +import app.worktrack.core.model.UserSession +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.SerializationException +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Persists the resolved user session (identity + tenant + roles) across process + * restarts so the app opens straight into offline mode. Auth *tokens* are never + * stored here — the Firebase SDK owns credential storage and refresh. + */ +@Singleton +class SessionStore @Inject constructor( + private val dataStore: DataStore, +) { + + @Serializable + private data class StoredSession( + val uid: String, + val companyId: String, + val employeeId: String, + val displayName: String, + val email: String, + val avatarUrl: String?, + val roles: List, + val branchIds: List, + val companyName: String, + val features: StoredFeatures = StoredFeatures(), + ) + + @Serializable + private data class StoredFeatures( + val shifts: Boolean = true, + val leave: Boolean = true, + val payroll: Boolean = true, + val regularization: Boolean = true, + val announcements: Boolean = true, + val geofencing: Boolean = true, + val qrKiosk: Boolean = true, + val faceRecognition: Boolean = true, + ) + + private val json = Json { ignoreUnknownKeys = true } + + val session: Flow = dataStore.data.map { prefs -> + prefs[KEY_SESSION]?.let { raw -> + try { + json.decodeFromString(raw).toModel() + } catch (_: SerializationException) { + null // Corrupt/legacy payload: treat as signed out rather than crash. + } + } + } + + suspend fun save(session: UserSession) { + dataStore.edit { prefs -> + prefs[KEY_SESSION] = json.encodeToString(session.toStored()) + } + } + + suspend fun clear() { + dataStore.edit { prefs -> prefs.remove(KEY_SESSION) } + } + + /** Whether the user has turned on the biometric app lock (fingerprint/face). */ + val biometricLockEnabled: Flow = dataStore.data.map { prefs -> + prefs[KEY_BIOMETRIC_LOCK] ?: false + } + + suspend fun setBiometricLock(enabled: Boolean) { + dataStore.edit { prefs -> prefs[KEY_BIOMETRIC_LOCK] = enabled } + } + + private fun StoredSession.toModel() = UserSession( + uid = uid, + companyId = companyId, + employeeId = employeeId, + displayName = displayName, + email = email, + avatarUrl = avatarUrl, + roles = roles.mapNotNull(RoleCode::fromCode).toSet(), + branchIds = branchIds, + companyName = companyName, + features = CompanyFeatures( + shifts = features.shifts, + leave = features.leave, + payroll = features.payroll, + regularization = features.regularization, + announcements = features.announcements, + geofencing = features.geofencing, + qrKiosk = features.qrKiosk, + faceRecognition = features.faceRecognition, + ), + ) + + private fun UserSession.toStored() = StoredSession( + uid = uid, + companyId = companyId, + employeeId = employeeId, + displayName = displayName, + email = email, + avatarUrl = avatarUrl, + roles = roles.map { it.name }, + branchIds = branchIds, + companyName = companyName, + features = StoredFeatures( + shifts = features.shifts, + leave = features.leave, + payroll = features.payroll, + regularization = features.regularization, + announcements = features.announcements, + geofencing = features.geofencing, + qrKiosk = features.qrKiosk, + faceRecognition = features.faceRecognition, + ), + ) + + private companion object { + val KEY_SESSION = stringPreferencesKey("user_session_v1") + val KEY_BIOMETRIC_LOCK = booleanPreferencesKey("biometric_lock_enabled") + } +} diff --git a/core/datastore/src/main/kotlin/app/worktrack/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/kotlin/app/worktrack/core/datastore/di/DataStoreModule.kt new file mode 100644 index 0000000..b5a0b03 --- /dev/null +++ b/core/datastore/src/main/kotlin/app/worktrack/core/datastore/di/DataStoreModule.kt @@ -0,0 +1,26 @@ +package app.worktrack.core.datastore.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +private val Context.sessionDataStore: DataStore by preferencesDataStore( + name = "worktrack_session", +) + +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + + @Provides + @Singleton + fun provideSessionDataStore(@ApplicationContext context: Context): DataStore = + context.sessionDataStore +} diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts new file mode 100644 index 0000000..5aed8bc --- /dev/null +++ b/core/designsystem/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.worktrack.android.library.compose) +} + +android { + namespace = "app.worktrack.core.designsystem" +} + +dependencies { + implementation(projects.core.common) + implementation(libs.androidx.compose.material.icons) +} diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Buttons.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Buttons.kt new file mode 100644 index 0000000..d35b16b --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Buttons.kt @@ -0,0 +1,63 @@ +package app.worktrack.core.designsystem.component + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Primary action button with a built-in loading state: while [loading] is true + * the button is disabled and shows a spinner, preventing double submission. + */ +@Composable +fun WtPrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + loading: Boolean = false, +) { + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled && !loading, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp), + ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text(text = text, style = MaterialTheme.typography.labelLarge) + } + } +} + +@Composable +fun WtSecondaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + OutlinedButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(text = text, style = MaterialTheme.typography.labelLarge) + } +} diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Scaffolding.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Scaffolding.kt new file mode 100644 index 0000000..2b1b2f4 --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/Scaffolding.kt @@ -0,0 +1,58 @@ +package app.worktrack.core.designsystem.component + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.worktrack.core.designsystem.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WtTopBar( + title: String, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, +) { + CenterAlignedTopAppBar( + title = { Text(title, style = MaterialTheme.typography.titleLarge) }, + modifier = modifier, + navigationIcon = { + if (onBack != null) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.ds_back), + ) + } + } + }, + actions = actions, + ) +} + +@Composable +fun SectionHeader( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) +} diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/States.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/States.kt new file mode 100644 index 0000000..0e6cf5e --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/States.kt @@ -0,0 +1,88 @@ +package app.worktrack.core.designsystem.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import app.worktrack.core.designsystem.R + +@Composable +fun FullScreenLoading(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +fun EmptyState( + icon: ImageVector, + title: String, + message: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center) + Spacer(Modifier.height(8.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +fun ErrorState( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(16.dp)) + WtSecondaryButton(text = stringResource(R.string.ds_retry), onClick = onRetry) + } +} diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/StatusChip.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/StatusChip.kt new file mode 100644 index 0000000..da31820 --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/StatusChip.kt @@ -0,0 +1,63 @@ +package app.worktrack.core.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import app.worktrack.core.designsystem.theme.StatusAmber +import app.worktrack.core.designsystem.theme.StatusAmberContainer +import app.worktrack.core.designsystem.theme.StatusGreen +import app.worktrack.core.designsystem.theme.StatusGreenContainer +import app.worktrack.core.designsystem.theme.StatusNeutral +import app.worktrack.core.designsystem.theme.StatusNeutralContainer +import app.worktrack.core.designsystem.theme.StatusRed +import app.worktrack.core.designsystem.theme.StatusRedContainer + +/** Semantic tone for status chips, mapped from domain enums at the call site. */ +enum class ChipTone { POSITIVE, WARNING, NEGATIVE, NEUTRAL } + +@Composable +fun StatusChip( + text: String, + tone: ChipTone, + modifier: Modifier = Modifier, +) { + val (container, content) = when (tone) { + ChipTone.POSITIVE -> StatusGreenContainer to StatusGreen + ChipTone.WARNING -> StatusAmberContainer to StatusAmber + ChipTone.NEGATIVE -> StatusRedContainer to StatusRed + ChipTone.NEUTRAL -> StatusNeutralContainer to StatusNeutral + } + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = content, + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(container) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) +} + +@Composable +fun ColorDotChip( + text: String, + dotColor: Color, + modifier: Modifier = Modifier, +) { + Text( + text = "● $text", + style = MaterialTheme.typography.labelMedium, + color = dotColor, + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) +} diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/TextFields.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/TextFields.kt new file mode 100644 index 0000000..01cf241 --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/component/TextFields.kt @@ -0,0 +1,42 @@ +package app.worktrack.core.designsystem.component + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.VisualTransformation + +/** Standard single-line form field with error slot wired for a11y. */ +@Composable +fun WtTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + errorText: String? = null, + enabled: Boolean = true, + singleLine: Boolean = true, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + label = { Text(label) }, + isError = errorText != null, + supportingText = errorText?.let { + { Text(text = it, style = MaterialTheme.typography.bodySmall) } + }, + enabled = enabled, + singleLine = singleLine, + keyboardOptions = keyboardOptions, + visualTransformation = visualTransformation, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + ) +} diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/l10n/AfghanFormat.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/l10n/AfghanFormat.kt new file mode 100644 index 0000000..c39ed86 --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/l10n/AfghanFormat.kt @@ -0,0 +1,113 @@ +package app.worktrack.core.designsystem.l10n + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringArrayResource +import app.worktrack.core.common.time.SolarHijri +import app.worktrack.core.common.time.SolarHijriDate +import app.worktrack.core.designsystem.R +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * Afghanistan-first formatting: Solar Hijri dates with Afghan month names and + * Extended Arabic-Indic digits (۰–۹) for Dari and Pashto locales. English + * shows the same Solar Hijri dates with transliterated month names — the + * business calendar of the platform is Solar Hijri regardless of language. + */ +object AfghanDigits { + + private val EASTERN = charArrayOf('۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹') + + fun usesEasternDigits(locale: Locale): Boolean = + locale.language == "fa" || locale.language == "ps" + + fun localize(input: String, locale: Locale): String { + if (!usesEasternDigits(locale)) return input + val out = StringBuilder(input.length) + for (ch in input) { + out.append(if (ch in '0'..'9') EASTERN[ch - '0'] else ch) + } + return out.toString() + } +} + +@Composable +fun appLocale(): Locale = LocalConfiguration.current.locales[0] ?: Locale.getDefault() + +/** Converts any Latin digits in [text] to ۰–۹ for Dari/Pashto locales. */ +@Composable +fun localizedDigits(text: String): String = AfghanDigits.localize(text, appLocale()) + +@Composable +fun shamsiMonthName(month: Int): String = + stringArrayResource(R.array.ds_shamsi_months)[(month - 1).coerceIn(0, 11)] + +@Composable +fun weekdayName(date: LocalDate): String = + stringArrayResource(R.array.ds_weekdays)[date.dayOfWeek.value - 1] + +/** "پنجشنبه ۲۶ سرطان" (withWeekday) or "۲۶ سرطان ۱۴۰۵" (withYear). */ +@Composable +fun formatShamsiDate( + date: LocalDate, + withWeekday: Boolean = false, + withYear: Boolean = false, +): String { + val shamsi = SolarHijri.fromGregorian(date) + val base = buildString { + if (withWeekday) { + append(weekdayName(date)) + append(' ') + } + append(shamsi.day) + append(' ') + append(shamsiMonthName(shamsi.month)) + if (withYear) { + append(' ') + append(shamsi.year) + } + } + return localizedDigits(base) +} + +/** "سرطان ۱۴۰۵" — month header labels. */ +@Composable +fun formatShamsiMonthYear(year: Int, month: Int): String = + localizedDigits("${shamsiMonthName(month)} $year") + +/** "۲۶ سرطان – ۲ اسد" — leave/date ranges (en-dash keeps RTL ordering intact). */ +@Composable +fun formatShamsiRange(start: LocalDate, end: LocalDate): String { + val s = SolarHijri.fromGregorian(start) + val e = SolarHijri.fromGregorian(end) + val text = if (s.year == e.year && s.month == e.month && s.day == e.day) { + "${s.day} ${shamsiMonthName(s.month)} ${s.year}" + } else { + "${s.day} ${shamsiMonthName(s.month)} – ${e.day} ${shamsiMonthName(e.month)} ${e.year}" + } + return localizedDigits(text) +} + +private val TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm") + +/** Wall-clock time in the device zone, digits localized. */ +@Composable +fun formatClockTime(instant: Instant): String = + localizedDigits(TIME_FORMAT.format(instant.atZone(ZoneId.systemDefault()))) + +/** "۲۶ سرطان ۱۴:۳۰" — compact timestamp for sync status rows. */ +@Composable +fun formatShamsiDateTime(instant: Instant): String { + val zoned = instant.atZone(ZoneId.systemDefault()) + val shamsi = SolarHijri.fromGregorian(zoned.toLocalDate()) + return localizedDigits( + "${shamsi.day} ${shamsiMonthName(shamsi.month)} ${TIME_FORMAT.format(zoned)}", + ) +} + +/** Current Solar Hijri date for "today" defaults in pickers/pagers. */ +fun shamsiToday(): SolarHijriDate = SolarHijri.fromGregorian(LocalDate.now()) diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/l10n/ErrorMessages.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/l10n/ErrorMessages.kt new file mode 100644 index 0000000..694e15c --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/l10n/ErrorMessages.kt @@ -0,0 +1,36 @@ +package app.worktrack.core.designsystem.l10n + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import app.worktrack.core.common.result.AppError +import app.worktrack.core.designsystem.R + +/** + * Localized, user-facing message for any [AppError]. Business errors map by + * their stable code; unknown codes fall back to the server-provided detail + * (already human-readable) and finally to the generic message. + */ +fun AppError.localizedMessage(context: Context): String = when (this) { + AppError.Network -> context.getString(R.string.ds_err_network) + AppError.Unauthenticated -> context.getString(R.string.ds_err_unauthenticated) + AppError.PermissionDenied -> context.getString(R.string.ds_err_permission) + AppError.NotFound -> context.getString(R.string.ds_err_not_found) + is AppError.Validation -> context.getString(R.string.ds_err_validation) + is AppError.Business -> when (code) { + "INVALID_CREDENTIALS" -> context.getString(R.string.ds_err_invalid_credentials) + "MOCK_LOCATION" -> context.getString(R.string.ds_err_mock_location) + "GEOFENCE_VIOLATION" -> context.getString(R.string.ds_err_geofence) + "INSUFFICIENT_LEAVE_BALANCE" -> context.getString(R.string.ds_err_leave_balance) + "NOT_SYNCED" -> context.getString(R.string.ds_err_not_synced) + "KIOSK_TOKEN_INVALID" -> context.getString(R.string.ds_err_kiosk_token) + "INVALID_STATE" -> context.getString(R.string.ds_err_invalid_state) + else -> message.ifBlank { context.getString(R.string.ds_err_unexpected) } + } + + is AppError.Http -> context.getString(R.string.ds_err_server, status.toString()) + is AppError.Unexpected -> context.getString(R.string.ds_err_unexpected) +} + +@Composable +fun AppError.localizedMessage(): String = localizedMessage(LocalContext.current) diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Color.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Color.kt new file mode 100644 index 0000000..326cf97 --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Color.kt @@ -0,0 +1,72 @@ +package app.worktrack.core.designsystem.theme + +import androidx.compose.ui.graphics.Color + +// WorkTrack brand palette. +// +// Deep blue #004E72 primary, coral #FF6D41 accent, on petrol #0A2735 and +// near-white #F9F9F9. Every on/container pair below was checked against WCAG AA +// for the Material 3 role it fills. +// +// Two tones are deliberately NOT the raw brand values: the light theme's +// tertiary is a darkened coral, because white text on #FF6D41 measures 2.65:1 +// and fails. The brand coral lives at tone 80, where it carries dark text. + +val Blue10 = Color(0xFF002333) +val Blue20 = Color(0xFF004666) +val Blue30 = Color(0xFF006999) +val Blue40 = Color(0xFF004E72) +val Blue80 = Color(0xFFB0D6E8) +val Blue90 = Color(0xFFD7EBF4) + +val Petrol10 = Color(0xFF0A2735) +val Petrol20 = Color(0xFF103F56) +val Petrol30 = Color(0xFF185F81) +val Petrol80 = Color(0xFFB9D3DF) +val Petrol90 = Color(0xFFDCE9EF) +val Petrol95 = Color(0xFFEDF4F7) +val Petrol99 = Color(0xFFF9F9F9) +val PetrolOutline = Color(0xFF6B8A99) + +// Layered light surfaces for a subtle elevation hierarchy (cards on background). +val Surface0 = Color(0xFFFFFFFF) +val SurfaceHighLight = Color(0xFFEEF3F6) + +// Layered dark surfaces: near-black base with progressively lighter containers. +val SurfaceDarkLowest = Color(0xFF061A24) +val SurfaceDark0 = Color(0xFF0A2735) +val SurfaceDark1 = Color(0xFF0E2E3E) +val SurfaceDark2 = Color(0xFF123646) +val SurfaceDark3 = Color(0xFF173E50) + +val Amber10 = Color(0xFF261A00) +val Amber20 = Color(0xFF402D00) +val Amber30 = Color(0xFF5C4200) +val Amber40 = Color(0xFF7A5900) +val Amber80 = Color(0xFFFABD1B) +val Amber90 = Color(0xFFFFDF9E) + +// Secondary accent — coral (M3 tertiary role), matches the web portal. +val Coral10 = Color(0xFF330C00) +val Coral20 = Color(0xFF601700) +val Coral30 = Color(0xFF992300) +val Coral40 = Color(0xFFCC2F00) +val Coral80 = Color(0xFFFF6D41) +val Coral90 = Color(0xFFF4DED7) + +val Red10 = Color(0xFF410002) +val Red20 = Color(0xFF690005) +val Red30 = Color(0xFF93000A) +val Red40 = Color(0xFFBA1A1A) +val Red80 = Color(0xFFFFB4AB) +val Red90 = Color(0xFFFFDAD6) + +// Semantic status colors (used by StatusChip; stable across light/dark). +val StatusGreen = Color(0xFF2E7D32) +val StatusGreenContainer = Color(0xFFC8E6C9) +val StatusAmber = Color(0xFF9A6B00) +val StatusAmberContainer = Color(0xFFFFE8B3) +val StatusRed = Color(0xFFB3261E) +val StatusRedContainer = Color(0xFFF9DEDC) +val StatusNeutral = Color(0xFF44606E) +val StatusNeutralContainer = Color(0xFFDFE9EE) diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Shape.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Shape.kt new file mode 100644 index 0000000..daf0a7b --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Shape.kt @@ -0,0 +1,17 @@ +package app.worktrack.core.designsystem.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +/** + * Slightly rounder than the M3 defaults for a modern, premium feel that matches + * the web portal. Cards land on `medium` (16dp), buttons/chips on `small`/`full`. + */ +val WorkTrackShapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(22.dp), + extraLarge = RoundedCornerShape(28.dp), +) diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Theme.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Theme.kt new file mode 100644 index 0000000..e7c6224 --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Theme.kt @@ -0,0 +1,103 @@ +package app.worktrack.core.designsystem.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val LightColors = lightColorScheme( + primary = Blue40, + onPrimary = Petrol99, + primaryContainer = Blue90, + onPrimaryContainer = Blue10, + secondary = Petrol30, + onSecondary = Petrol99, + secondaryContainer = Petrol90, + onSecondaryContainer = Petrol10, + tertiary = Coral40, + onTertiary = Petrol99, + tertiaryContainer = Coral90, + onTertiaryContainer = Coral10, + error = Red40, + onError = Petrol99, + errorContainer = Red90, + onErrorContainer = Red10, + background = Petrol99, + onBackground = Petrol10, + surface = Surface0, + onSurface = Petrol10, + surfaceVariant = Petrol95, + onSurfaceVariant = Petrol30, + surfaceTint = Blue40, + surfaceContainerLowest = Surface0, + surfaceContainerLow = Petrol99, + surfaceContainer = Petrol95, + surfaceContainerHigh = SurfaceHighLight, + surfaceContainerHighest = Petrol90, + outline = PetrolOutline, + outlineVariant = Petrol90, +) + +private val DarkColors = darkColorScheme( + primary = Blue80, + onPrimary = Blue20, + primaryContainer = Blue30, + onPrimaryContainer = Blue90, + secondary = Petrol80, + onSecondary = Petrol20, + secondaryContainer = Petrol30, + onSecondaryContainer = Petrol90, + tertiary = Coral80, + onTertiary = Coral20, + tertiaryContainer = Coral30, + onTertiaryContainer = Coral90, + error = Red80, + onError = Red20, + errorContainer = Red30, + onErrorContainer = Red90, + background = SurfaceDark0, + onBackground = Petrol90, + surface = SurfaceDark0, + onSurface = Petrol90, + surfaceVariant = Petrol30, + onSurfaceVariant = Petrol80, + surfaceTint = Blue80, + surfaceContainerLowest = SurfaceDarkLowest, + surfaceContainerLow = SurfaceDark1, + surfaceContainer = SurfaceDark2, + surfaceContainerHigh = SurfaceDark3, + surfaceContainerHighest = Petrol30, + outline = Petrol80, + outlineVariant = Petrol30, +) + +@Composable +fun WorkTrackTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Brand colors by default: a workforce app should look identical across the + // fleet; dynamic color is an opt-in for personal devices. + dynamicColor: Boolean = false, + content: @Composable () -> Unit, +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColors + else -> LightColors + } + + MaterialTheme( + colorScheme = colorScheme, + typography = WorkTrackTypography, + shapes = WorkTrackShapes, + content = content, + ) +} diff --git a/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Type.kt b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Type.kt new file mode 100644 index 0000000..8d95b74 --- /dev/null +++ b/core/designsystem/src/main/kotlin/app/worktrack/core/designsystem/theme/Type.kt @@ -0,0 +1,81 @@ +package app.worktrack.core.designsystem.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * M3 default type scale with tightened display/headline weights. System font + * keeps APK size down and respects user font-scale accessibility settings. + */ +val WorkTrackTypography = Typography( + headlineLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, + lineHeight = 40.sp, + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + ), + headlineSmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + ), + titleLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 28.sp, + ), + titleMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + titleSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), +) diff --git a/core/designsystem/src/main/res/values-en/strings.xml b/core/designsystem/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..0687e41 --- /dev/null +++ b/core/designsystem/src/main/res/values-en/strings.xml @@ -0,0 +1,49 @@ + + + Retry + Back + OK + Cancel + + You\'re offline. Changes are saved and will sync automatically. + Your session has expired. Please sign in again. + You don\'t have permission to do that. + That item could not be found. + Something went wrong. Please try again. + A server error occurred (%1$s). + The entered information is not valid. + + Email or password is incorrect. + Mock locations are not allowed for attendance. + You are outside the allowed work area. + Your leave balance is not sufficient. + Wait for this request to finish syncing first. + The kiosk QR code is invalid; scan it again. + This request has already been finalized. + + + + Hamal + Sawr + Jawza + Saratan + Asad + Sunbula + Mizan + Aqrab + Qaws + Jadi + Dalw + Hut + + + + Mon + Tue + Wed + Thu + Fri + Sat + Sun + + diff --git a/core/designsystem/src/main/res/values-ps/strings.xml b/core/designsystem/src/main/res/values-ps/strings.xml new file mode 100644 index 0000000..8e727d2 --- /dev/null +++ b/core/designsystem/src/main/res/values-ps/strings.xml @@ -0,0 +1,48 @@ + + + بیا هڅه + شاته + سمه ده + لغوه + + تاسو آفلاین یاست. بدلونونه خوندي شول او په خپلکاره توګه به همغږي شي. + ستاسو ناسته پای ته ورسېده. مهرباني وکړئ بیا ننوځئ. + تاسو د دې کار اجازه نه لرئ. + توکی ونه موندل شو. + ستونزه رامنځته شوه. مهرباني وکړئ بیا هڅه وکړئ. + د سرور تېروتنه وشوه (%1$s). + ورکړل شوي معلومات سم نه دي. + + برېښنالیک یا پټنوم سم نه دی. + جعلي موقعیت د حاضرۍ لپاره مجاز نه دی. + تاسو د مجازې کاري ساحې بهر یاست. + ستاسو د رخصتۍ بیلانس بسنه نه کوي. + صبر وکړئ چې دا غوښتنه لومړی همغږي شي. + د کیوسک QR کوډ سم نه دی؛ بیا یې سکن کړئ. + دا غوښتنه له مخکې پای ته رسېدلې ده. + + + وری + غويی + غبرګولی + چنګاښ + زمری + وږی + تله + لړم + ليندۍ + مرغومی + سلواغه + کب + + + + دوشنبه + درېشنبه + څلورشنبه + پينځشنبه + جمعه + شنبه + یکشنبه + + diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml new file mode 100644 index 0000000..490a35d --- /dev/null +++ b/core/designsystem/src/main/res/values/strings.xml @@ -0,0 +1,52 @@ + + + + تلاش دوباره + بازگشت + تایید + لغو + + آفلاین هستید. تغییرات ذخیره شد و به صورت خودکار همگام می‌شود. + نشست شما پایان یافته است. لطفاً دوباره وارد شوید. + اجازهٔ این کار را ندارید. + مورد موردنظر یافت نشد. + مشکلی پیش آمد. لطفاً دوباره تلاش کنید. + خطای سرور رخ داد (%1$s). + معلومات واردشده درست نیست. + + ایمیل یا رمز عبور نادرست است. + استفاده از موقعیت جعلی برای حاضری مجاز نیست. + شما خارج از ساحهٔ کاری مجاز هستید. + بیلانس رخصتی شما کافی نیست. + صبر کنید تا این درخواست اول همگام شود. + کود QR کیوسک معتبر نیست؛ دوباره اسکن کنید. + این درخواست قبلاً نهایی شده است. + + + + حمل + ثور + جوزا + سرطان + اسد + سنبله + میزان + عقرب + قوس + جدی + دلو + حوت + + + + + دوشنبه + سه‌شنبه + چهارشنبه + پنجشنبه + جمعه + شنبه + یکشنبه + + diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts new file mode 100644 index 0000000..da1f7dd --- /dev/null +++ b/core/domain/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.worktrack.jvm.library) +} + +dependencies { + api(projects.core.common) + api(projects.core.model) + implementation(libs.javax.inject) + + testImplementation(libs.turbine) +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AnnouncementRepository.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AnnouncementRepository.kt new file mode 100644 index 0000000..bf74a32 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AnnouncementRepository.kt @@ -0,0 +1,13 @@ +package app.worktrack.core.domain.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.model.Announcement +import kotlinx.coroutines.flow.Flow + +interface AnnouncementRepository { + + /** Currently visible announcements (published, not expired), newest first. */ + fun observeAnnouncements(): Flow> + + suspend fun refresh(): AppResult +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AttendanceRepository.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AttendanceRepository.kt new file mode 100644 index 0000000..f74411f --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AttendanceRepository.kt @@ -0,0 +1,40 @@ +package app.worktrack.core.domain.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.model.AttendanceDay +import app.worktrack.core.model.AttendancePunch +import app.worktrack.core.model.Geofence +import app.worktrack.core.model.PunchCommand +import app.worktrack.core.model.RegularizationCommand +import app.worktrack.core.model.TodayAttendance +import java.time.LocalDate +import kotlinx.coroutines.flow.Flow + +interface AttendanceRepository { + + /** Live view of the current day: punches so far, clocked-in state, today's shift. */ + fun observeToday(): Flow + + fun observeDays(from: LocalDate, to: LocalDate): Flow> + + fun observePunches(from: LocalDate, to: LocalDate): Flow> + + fun observeActiveGeofences(): Flow> + + /** + * Records a punch offline-first: persists locally with PENDING sync status, + * enqueues an outbox operation, and requests an immediate sync. Never blocks + * on the network — server validation results reconcile asynchronously. + */ + suspend fun punch(command: PunchCommand): AppResult + + /** + * Files an attendance-correction request offline-first (outbox + immediate + * sync). A manager approves it in the portal; the corrected day arrives back + * through the normal attendance pull. Server re-validates on sync. + */ + suspend fun requestRegularization(command: RegularizationCommand): AppResult + + /** Pulls the given window of attendance days/punches from the server into Room. */ + suspend fun refresh(from: LocalDate, to: LocalDate): AppResult +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AuthRepository.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..fb33296 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/AuthRepository.kt @@ -0,0 +1,30 @@ +package app.worktrack.core.domain.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.model.UserSession +import kotlinx.coroutines.flow.Flow + +interface AuthRepository { + + /** Emits the current session, or null when signed out. Backed by DataStore. */ + val session: Flow + + /** Whether the biometric app lock (fingerprint/face) is turned on. */ + val biometricLockEnabled: Flow + + suspend fun setBiometricLock(enabled: Boolean) + + /** + * Authenticates against Firebase Auth, then resolves tenant context via + * GET /me and persists the session locally. + */ + suspend fun signIn(email: String, password: String): AppResult + + /** Re-fetches GET /me (roles/claims may have changed) and updates the stored session. */ + suspend fun refreshSession(): AppResult + + suspend fun sendPasswordReset(email: String): AppResult + + /** Signs out of Firebase, clears the session, local database, and pending outbox. */ + suspend fun signOut() +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/FaceRepository.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/FaceRepository.kt new file mode 100644 index 0000000..b69e46f --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/FaceRepository.kt @@ -0,0 +1,24 @@ +package app.worktrack.core.domain.repository + +import app.worktrack.core.common.result.AppResult + +/** On-device face enrollment and verification (only embeddings, never photos). */ +interface FaceRepository { + + /** Enroll the current user's face embedding. */ + suspend fun enroll(embedding: List): AppResult + + /** Verify a check-in embedding against the enrolled one. */ + suspend fun verify(embedding: List): AppResult +} + +data class FaceVerification( + val match: Boolean, + val similarity: Float, + val enrolled: Boolean, + /** + * Server-signed proof of the match, sent with the punch so the server can + * trust it. Null unless [match] is true; short-lived. + */ + val token: String? = null, +) diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/LeaveRepository.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/LeaveRepository.kt new file mode 100644 index 0000000..eef0c2e --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/LeaveRepository.kt @@ -0,0 +1,35 @@ +package app.worktrack.core.domain.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.model.ApprovalDecision +import app.worktrack.core.model.LeaveApplication +import app.worktrack.core.model.LeaveBalance +import app.worktrack.core.model.LeaveRequest +import app.worktrack.core.model.LeaveType +import kotlinx.coroutines.flow.Flow + +interface LeaveRepository { + + fun observeTypes(): Flow> + + fun observeMyBalances(periodYear: Int): Flow> + + fun observeMyRequests(): Flow> + + /** Requests awaiting the current user's decision. Empty for non-approvers. */ + fun observePendingApprovals(): Flow> + + /** + * Creates a request offline-first (local insert + outbox). The server is + * authoritative on balances and may reject on sync; rejection surfaces as a + * FAILED sync status plus a notification, never silent loss. + */ + suspend fun apply(application: LeaveApplication): AppResult + + suspend fun cancel(requestId: String): AppResult + + /** Approve/reject as the current approver. Requires connectivity (server-authoritative). */ + suspend fun decide(requestId: String, decision: ApprovalDecision, note: String?): AppResult + + suspend fun refresh(): AppResult +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/PayslipRepository.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/PayslipRepository.kt new file mode 100644 index 0000000..055277c --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/PayslipRepository.kt @@ -0,0 +1,14 @@ +package app.worktrack.core.domain.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.model.Payslip +import kotlinx.coroutines.flow.Flow + +interface PayslipRepository { + + fun observePayslips(periodYear: Int): Flow> + + fun observePayslip(payslipId: String): Flow + + suspend fun refresh(periodYear: Int): AppResult +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/SyncRepository.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/SyncRepository.kt new file mode 100644 index 0000000..2402429 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/SyncRepository.kt @@ -0,0 +1,31 @@ +package app.worktrack.core.domain.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.model.SyncState +import kotlinx.coroutines.flow.Flow + +/** + * The client sync engine: drains the outbox (push) then applies server deltas (pull). + * Invoked by WorkManager; UI observes [observeSyncState] for health. + */ +interface SyncRepository { + + fun observeSyncState(): Flow + + /** + * One full sync cycle: push pending outbox operations in FIFO order per + * resource, then delta-pull every replicated resource type. Idempotent — + * safe to call concurrently or repeatedly. + */ + suspend fun syncNow(): AppResult +} + +/** Schedules sync work; implemented with WorkManager in :core:sync. */ +interface SyncScheduler { + + /** Ensures the periodic background sync is registered (idempotent). */ + fun schedulePeriodicSync() + + /** Requests an expedited one-off sync, e.g. right after a punch. */ + fun requestImmediateSync() +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/WorkRepository.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/WorkRepository.kt new file mode 100644 index 0000000..e4094a5 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/repository/WorkRepository.kt @@ -0,0 +1,23 @@ +package app.worktrack.core.domain.repository + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.model.TaskStatus +import app.worktrack.core.model.WorkTask +import java.time.LocalDate +import kotlinx.coroutines.flow.Flow + +interface WorkRepository { + + /** + * Every task live on any day in [from]..[to], from the local database. + * + * Reads work offline: a site with no signal is the normal case, and the + * plan for the day was pulled the last time there was any. + */ + fun observeTasks(from: LocalDate, to: LocalDate): Flow> + + /** Report progress on one of your own tasks. Requires a connection. */ + suspend fun setStatus(taskId: String, status: TaskStatus): AppResult + + suspend fun refresh(): AppResult +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/EvaluateGeofenceUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/EvaluateGeofenceUseCase.kt new file mode 100644 index 0000000..35291f3 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/EvaluateGeofenceUseCase.kt @@ -0,0 +1,62 @@ +package app.worktrack.core.domain.usecase.attendance + +import app.worktrack.core.common.geo.GeoDistance +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.model.Geofence +import javax.inject.Inject +import kotlinx.coroutines.flow.first + +/** + * Result of matching a device location against the company's active geofences. + * + * @property fencesConfigured false when the tenant has no active fences, in which + * case punching from anywhere is permitted (small businesses without offices). + */ +data class GeofenceEvaluation( + val fencesConfigured: Boolean, + val nearestFence: Geofence?, + val distanceMeters: Double?, + val insideFence: Boolean, +) + +class EvaluateGeofenceUseCase @Inject constructor( + private val attendanceRepository: AttendanceRepository, +) { + + suspend operator fun invoke( + latitude: Double, + longitude: Double, + accuracyMeters: Float?, + ): GeofenceEvaluation { + val fences = attendanceRepository.observeActiveGeofences().first() + if (fences.isEmpty()) { + return GeofenceEvaluation( + fencesConfigured = false, + nearestFence = null, + distanceMeters = null, + insideFence = false, + ) + } + + val measured = fences + .map { it to GeoDistance.meters(latitude, longitude, it.latitude, it.longitude) } + + // GPS accuracy is credited toward the fence: a reading whose error circle + // overlaps the fence counts as inside, so poor urban GPS doesn't lock people out. + val slack = accuracyMeters ?: 0f + // Being inside ANY fence is enough. Judging only the closest centre shut + // out someone standing well inside a large site because a small fence + // happened to be centred nearer — mirrors the server's checkGeofence. + val containing = measured + .filter { (fence, d) -> d - slack <= fence.radiusMeters } + .minByOrNull { (_, d) -> d } + val (nearest, distance) = containing ?: measured.minBy { (_, d) -> d } + + return GeofenceEvaluation( + fencesConfigured = true, + nearestFence = nearest, + distanceMeters = distance, + insideFence = containing != null, + ) + } +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/ObserveAttendanceHistoryUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/ObserveAttendanceHistoryUseCase.kt new file mode 100644 index 0000000..042854c --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/ObserveAttendanceHistoryUseCase.kt @@ -0,0 +1,16 @@ +package app.worktrack.core.domain.usecase.attendance + +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.model.AttendanceDay +import java.time.YearMonth +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class ObserveAttendanceHistoryUseCase @Inject constructor( + private val attendanceRepository: AttendanceRepository, +) { + + /** Attendance days for one calendar month, newest first (per DAO ordering). */ + operator fun invoke(month: YearMonth): Flow> = + attendanceRepository.observeDays(month.atDay(1), month.atEndOfMonth()) +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/ObserveTodayAttendanceUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/ObserveTodayAttendanceUseCase.kt new file mode 100644 index 0000000..13b4732 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/ObserveTodayAttendanceUseCase.kt @@ -0,0 +1,12 @@ +package app.worktrack.core.domain.usecase.attendance + +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.model.TodayAttendance +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class ObserveTodayAttendanceUseCase @Inject constructor( + private val attendanceRepository: AttendanceRepository, +) { + operator fun invoke(): Flow = attendanceRepository.observeToday() +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/PunchClockUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/PunchClockUseCase.kt new file mode 100644 index 0000000..f855d49 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/PunchClockUseCase.kt @@ -0,0 +1,77 @@ +package app.worktrack.core.domain.usecase.attendance + +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.onSuccess +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.domain.repository.SyncScheduler +import app.worktrack.core.model.AttendancePunch +import app.worktrack.core.model.PunchCommand +import app.worktrack.core.model.PunchMethod +import javax.inject.Inject + +/** + * Client-side gate for recording a punch. The server re-validates everything; + * these checks exist to fail fast with actionable feedback while offline. + */ +class PunchClockUseCase @Inject constructor( + private val attendanceRepository: AttendanceRepository, + private val evaluateGeofence: EvaluateGeofenceUseCase, + private val syncScheduler: SyncScheduler, +) { + + suspend operator fun invoke(command: PunchCommand): AppResult { + if (command.isMockLocation) { + return AppResult.failure( + AppError.Business( + code = "MOCK_LOCATION", + message = "Mock locations are not allowed for attendance", + ), + ) + } + + val enriched = when (command.method) { + // FACE is a GPS punch with an identity check on top: the server runs + // the same geofence validation on both. Letting FACE skip the client + // gate meant the employee was told "recorded" for a punch the server + // then marked invalid, and it never reached the attendance board. + PunchMethod.GPS, PunchMethod.FACE -> { + val lat = command.latitude + val lng = command.longitude + if (lat == null || lng == null) { + return AppResult.failure( + AppError.Validation("A location fix is required for GPS punch"), + ) + } + val evaluation = evaluateGeofence(lat, lng, command.accuracyMeters) + if (evaluation.fencesConfigured && !evaluation.insideFence) { + return AppResult.failure( + AppError.Business( + code = "GEOFENCE_VIOLATION", + message = "You are outside the allowed work area" + + (evaluation.nearestFence?.let { " (${it.name})" } ?: ""), + ), + ) + } + command.copy( + geofenceId = evaluation.nearestFence?.id, + insideFence = evaluation.insideFence, + ) + } + + PunchMethod.QR -> { + if (command.kioskToken.isNullOrBlank()) { + return AppResult.failure( + AppError.Validation("Kiosk QR token missing — rescan the code"), + ) + } + command + } + + else -> command + } + + return attendanceRepository.punch(enriched) + .onSuccess { syncScheduler.requestImmediateSync() } + } +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/RequestRegularizationUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/RequestRegularizationUseCase.kt new file mode 100644 index 0000000..518ae5d --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/attendance/RequestRegularizationUseCase.kt @@ -0,0 +1,51 @@ +package app.worktrack.core.domain.usecase.attendance + +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.onSuccess +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.domain.repository.SyncScheduler +import app.worktrack.core.model.RegularizationCommand +import javax.inject.Inject + +/** + * Client-side gate for filing an attendance correction. The server is + * authoritative and re-validates on sync; these checks fail fast with + * actionable feedback while the employee is still on the form. + */ +class RequestRegularizationUseCase @Inject constructor( + private val attendanceRepository: AttendanceRepository, + private val timeProvider: TimeProvider, + private val syncScheduler: SyncScheduler, +) { + + suspend operator fun invoke(command: RegularizationCommand): AppResult { + val fieldErrors = buildMap { + if (command.requestedInAt == null && command.requestedOutAt == null) { + put("times", "Provide a corrected check-in or check-out time") + } + if (command.reason.isBlank()) put("reason", "A reason is required") + if (command.date.isAfter(timeProvider.today())) { + put("date", "Cannot correct a future date") + } + } + if (fieldErrors.isNotEmpty()) { + return AppResult.failure(AppError.Validation("Fix the highlighted fields", fieldErrors)) + } + + val inAt = command.requestedInAt + val outAt = command.requestedOutAt + if (inAt != null && outAt != null && !outAt.isAfter(inAt)) { + return AppResult.failure( + AppError.Validation( + "Check-out must be after check-in", + mapOf("times" to "Check-out must be after check-in"), + ), + ) + } + + return attendanceRepository.requestRegularization(command) + .onSuccess { syncScheduler.requestImmediateSync() } + } +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/BiometricLockUseCases.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/BiometricLockUseCases.kt new file mode 100644 index 0000000..323580f --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/BiometricLockUseCases.kt @@ -0,0 +1,19 @@ +package app.worktrack.core.domain.usecase.auth + +import app.worktrack.core.domain.repository.AuthRepository +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +/** Observes whether the biometric app lock (fingerprint/face) is enabled. */ +class ObserveBiometricLockUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + operator fun invoke(): Flow = authRepository.biometricLockEnabled +} + +/** Turns the biometric app lock on or off. */ +class SetBiometricLockUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke(enabled: Boolean) = authRepository.setBiometricLock(enabled) +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/ObserveSessionUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/ObserveSessionUseCase.kt new file mode 100644 index 0000000..0c1da3e --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/ObserveSessionUseCase.kt @@ -0,0 +1,12 @@ +package app.worktrack.core.domain.usecase.auth + +import app.worktrack.core.domain.repository.AuthRepository +import app.worktrack.core.model.UserSession +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class ObserveSessionUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + operator fun invoke(): Flow = authRepository.session +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/SignInUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/SignInUseCase.kt new file mode 100644 index 0000000..3c36af2 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/SignInUseCase.kt @@ -0,0 +1,40 @@ +package app.worktrack.core.domain.usecase.auth + +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.domain.repository.AuthRepository +import app.worktrack.core.domain.repository.SyncScheduler +import app.worktrack.core.model.UserSession +import javax.inject.Inject + +class SignInUseCase @Inject constructor( + private val authRepository: AuthRepository, + private val syncScheduler: SyncScheduler, +) { + + suspend operator fun invoke(email: String, password: String): AppResult { + val trimmedEmail = email.trim() + val fieldErrors = buildMap { + if (!EMAIL_REGEX.matches(trimmedEmail)) put("email", "Enter a valid email address") + if (password.length < MIN_PASSWORD_LENGTH) { + put("password", "Password must be at least $MIN_PASSWORD_LENGTH characters") + } + } + if (fieldErrors.isNotEmpty()) { + return AppResult.failure(AppError.Validation("Check your credentials", fieldErrors)) + } + return authRepository.signIn(trimmedEmail, password) + .also { result -> + if (result is AppResult.Success) { + // First sign-in on a device triggers the initial bootstrap sync. + syncScheduler.schedulePeriodicSync() + syncScheduler.requestImmediateSync() + } + } + } + + private companion object { + const val MIN_PASSWORD_LENGTH = 8 + val EMAIL_REGEX = Regex("^[A-Za-z0-9+_.\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}$") + } +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/SignOutUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/SignOutUseCase.kt new file mode 100644 index 0000000..0f789e7 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/auth/SignOutUseCase.kt @@ -0,0 +1,10 @@ +package app.worktrack.core.domain.usecase.auth + +import app.worktrack.core.domain.repository.AuthRepository +import javax.inject.Inject + +class SignOutUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke() = authRepository.signOut() +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/dashboard/ObserveDashboardUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/dashboard/ObserveDashboardUseCase.kt new file mode 100644 index 0000000..66de8ec --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/dashboard/ObserveDashboardUseCase.kt @@ -0,0 +1,58 @@ +package app.worktrack.core.domain.usecase.dashboard + +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.domain.repository.AnnouncementRepository +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.domain.repository.AuthRepository +import app.worktrack.core.domain.repository.LeaveRepository +import app.worktrack.core.domain.usecase.work.ObserveMyWorkUseCase +import app.worktrack.core.model.Announcement +import app.worktrack.core.model.LeaveBalance +import app.worktrack.core.model.MyWork +import app.worktrack.core.model.TodayAttendance +import app.worktrack.core.model.UserSession +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +data class DashboardSnapshot( + val session: UserSession, + val today: TodayAttendance, + val leaveBalances: List, + val announcements: List, + /** Which part of the job this person is on today, and next. */ + val myWork: MyWork, +) + +class ObserveDashboardUseCase @Inject constructor( + private val authRepository: AuthRepository, + private val attendanceRepository: AttendanceRepository, + private val leaveRepository: LeaveRepository, + private val announcementRepository: AnnouncementRepository, + private val observeMyWork: ObserveMyWorkUseCase, + private val timeProvider: TimeProvider, +) { + + /** Emits null while signed out; the app shell redirects to auth in that case. */ + operator fun invoke(): Flow = combine( + authRepository.session, + attendanceRepository.observeToday(), + leaveRepository.observeMyBalances(timeProvider.today().year), + announcementRepository.observeAnnouncements(), + observeMyWork(), + ) { session, today, balances, announcements, myWork -> + session?.let { + DashboardSnapshot( + session = it, + today = today, + leaveBalances = balances, + announcements = announcements.take(MAX_DASHBOARD_ANNOUNCEMENTS), + myWork = myWork, + ) + } + } + + private companion object { + const val MAX_DASHBOARD_ANNOUNCEMENTS = 5 + } +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ApplyLeaveUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ApplyLeaveUseCase.kt new file mode 100644 index 0000000..b58a813 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ApplyLeaveUseCase.kt @@ -0,0 +1,82 @@ +package app.worktrack.core.domain.usecase.leave + +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.onSuccess +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.domain.repository.LeaveRepository +import app.worktrack.core.domain.repository.SyncScheduler +import app.worktrack.core.model.LeaveApplication +import app.worktrack.core.model.LeaveRequest +import java.time.LocalDate +import java.time.temporal.ChronoUnit +import javax.inject.Inject +import kotlinx.coroutines.flow.first + +class ApplyLeaveUseCase @Inject constructor( + private val leaveRepository: LeaveRepository, + private val timeProvider: TimeProvider, + private val syncScheduler: SyncScheduler, +) { + + suspend operator fun invoke(application: LeaveApplication): AppResult { + val fieldErrors = buildMap { + if (application.endDate.isBefore(application.startDate)) { + put("endDate", "End date must be on or after the start date") + } + if (application.reason.isBlank()) put("reason", "A reason is required") + if (application.startDate.isBefore(timeProvider.today().minusDays(MAX_BACKDATE_DAYS))) { + put("startDate", "Leave cannot start more than $MAX_BACKDATE_DAYS days in the past") + } + } + if (fieldErrors.isNotEmpty()) { + return AppResult.failure(AppError.Validation("Fix the highlighted fields", fieldErrors)) + } + + val days = calculateDays(application) + if (days <= 0.0) { + return AppResult.failure(AppError.Validation("The selected range is empty")) + } + + // Best-effort local balance check for immediate feedback; the server holds + // the authoritative balance and re-validates on sync. + val balance = leaveRepository + .observeMyBalances(application.startDate.year).first() + .firstOrNull { it.leaveTypeId == application.leaveTypeId } + if (balance != null && days > balance.availableDays) { + return AppResult.failure( + AppError.Business( + code = "INSUFFICIENT_LEAVE_BALANCE", + message = "Requested %.1f days but only %.1f available" + .format(days, balance.availableDays), + ), + ) + } + + return leaveRepository.apply(application) + .onSuccess { syncScheduler.requestImmediateSync() } + } + + companion object { + private const val MAX_BACKDATE_DAYS = 30L + + /** + * Calendar-day count with half-day adjustments. Weekend/holiday exclusion + * depends on branch calendars and is applied server-side; this figure is + * the client-side estimate shown before submission. + */ + fun calculateDays(application: LeaveApplication): Double { + val span = ChronoUnit.DAYS.between(application.startDate, application.endDate) + 1 + if (span <= 0) return 0.0 + if (isSingleDay(application.startDate, application.endDate)) { + return if (application.startHalfDay || application.endHalfDay) 0.5 else 1.0 + } + var days = span.toDouble() + if (application.startHalfDay) days -= 0.5 + if (application.endHalfDay) days -= 0.5 + return days + } + + private fun isSingleDay(start: LocalDate, end: LocalDate) = start == end + } +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/CancelLeaveRequestUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/CancelLeaveRequestUseCase.kt new file mode 100644 index 0000000..dae7e3b --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/CancelLeaveRequestUseCase.kt @@ -0,0 +1,12 @@ +package app.worktrack.core.domain.usecase.leave + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.domain.repository.LeaveRepository +import javax.inject.Inject + +class CancelLeaveRequestUseCase @Inject constructor( + private val leaveRepository: LeaveRepository, +) { + suspend operator fun invoke(requestId: String): AppResult = + leaveRepository.cancel(requestId) +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/DecideLeaveRequestUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/DecideLeaveRequestUseCase.kt new file mode 100644 index 0000000..3ec096b --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/DecideLeaveRequestUseCase.kt @@ -0,0 +1,25 @@ +package app.worktrack.core.domain.usecase.leave + +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.domain.repository.LeaveRepository +import app.worktrack.core.model.ApprovalDecision +import javax.inject.Inject + +class DecideLeaveRequestUseCase @Inject constructor( + private val leaveRepository: LeaveRepository, +) { + + suspend operator fun invoke( + requestId: String, + decision: ApprovalDecision, + note: String?, + ): AppResult { + if (decision == ApprovalDecision.REJECT && note.isNullOrBlank()) { + return AppResult.failure( + AppError.Validation("A note is required when rejecting", mapOf("note" to "Required")), + ) + } + return leaveRepository.decide(requestId, decision, note?.trim()?.takeIf { it.isNotEmpty() }) + } +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ObserveLeaveOverviewUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ObserveLeaveOverviewUseCase.kt new file mode 100644 index 0000000..72d97b4 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ObserveLeaveOverviewUseCase.kt @@ -0,0 +1,33 @@ +package app.worktrack.core.domain.usecase.leave + +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.domain.repository.LeaveRepository +import app.worktrack.core.model.LeaveBalance +import app.worktrack.core.model.LeaveRequest +import app.worktrack.core.model.LeaveType +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +/** Everything the leave screen needs, joined so type metadata is always resolvable. */ +data class LeaveOverview( + val types: List, + val balances: List, + val myRequests: List, +) { + fun typeOf(leaveTypeId: String): LeaveType? = types.firstOrNull { it.id == leaveTypeId } +} + +class ObserveLeaveOverviewUseCase @Inject constructor( + private val leaveRepository: LeaveRepository, + private val timeProvider: TimeProvider, +) { + + operator fun invoke(): Flow = combine( + leaveRepository.observeTypes(), + leaveRepository.observeMyBalances(timeProvider.today().year), + leaveRepository.observeMyRequests(), + ) { types, balances, requests -> + LeaveOverview(types = types, balances = balances, myRequests = requests) + } +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ObservePendingApprovalsUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ObservePendingApprovalsUseCase.kt new file mode 100644 index 0000000..e2204b9 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/leave/ObservePendingApprovalsUseCase.kt @@ -0,0 +1,12 @@ +package app.worktrack.core.domain.usecase.leave + +import app.worktrack.core.domain.repository.LeaveRepository +import app.worktrack.core.model.LeaveRequest +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class ObservePendingApprovalsUseCase @Inject constructor( + private val leaveRepository: LeaveRepository, +) { + operator fun invoke(): Flow> = leaveRepository.observePendingApprovals() +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/payslip/ObservePayslipDetailUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/payslip/ObservePayslipDetailUseCase.kt new file mode 100644 index 0000000..6821e88 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/payslip/ObservePayslipDetailUseCase.kt @@ -0,0 +1,13 @@ +package app.worktrack.core.domain.usecase.payslip + +import app.worktrack.core.domain.repository.PayslipRepository +import app.worktrack.core.model.Payslip +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class ObservePayslipDetailUseCase @Inject constructor( + private val payslipRepository: PayslipRepository, +) { + operator fun invoke(payslipId: String): Flow = + payslipRepository.observePayslip(payslipId) +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/payslip/ObservePayslipsUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/payslip/ObservePayslipsUseCase.kt new file mode 100644 index 0000000..e2b5c76 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/payslip/ObservePayslipsUseCase.kt @@ -0,0 +1,13 @@ +package app.worktrack.core.domain.usecase.payslip + +import app.worktrack.core.domain.repository.PayslipRepository +import app.worktrack.core.model.Payslip +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class ObservePayslipsUseCase @Inject constructor( + private val payslipRepository: PayslipRepository, +) { + operator fun invoke(periodYear: Int): Flow> = + payslipRepository.observePayslips(periodYear) +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/sync/ObserveSyncStateUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/sync/ObserveSyncStateUseCase.kt new file mode 100644 index 0000000..d890d2c --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/sync/ObserveSyncStateUseCase.kt @@ -0,0 +1,12 @@ +package app.worktrack.core.domain.usecase.sync + +import app.worktrack.core.domain.repository.SyncRepository +import app.worktrack.core.model.SyncState +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class ObserveSyncStateUseCase @Inject constructor( + private val syncRepository: SyncRepository, +) { + operator fun invoke(): Flow = syncRepository.observeSyncState() +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/sync/TriggerSyncUseCase.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/sync/TriggerSyncUseCase.kt new file mode 100644 index 0000000..f8556ba --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/sync/TriggerSyncUseCase.kt @@ -0,0 +1,10 @@ +package app.worktrack.core.domain.usecase.sync + +import app.worktrack.core.domain.repository.SyncScheduler +import javax.inject.Inject + +class TriggerSyncUseCase @Inject constructor( + private val syncScheduler: SyncScheduler, +) { + operator fun invoke() = syncScheduler.requestImmediateSync() +} diff --git a/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/work/WorkUseCases.kt b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/work/WorkUseCases.kt new file mode 100644 index 0000000..112b295 --- /dev/null +++ b/core/domain/src/main/kotlin/app/worktrack/core/domain/usecase/work/WorkUseCases.kt @@ -0,0 +1,76 @@ +package app.worktrack.core.domain.usecase.work + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.domain.repository.WorkRepository +import app.worktrack.core.model.DayKind +import app.worktrack.core.model.MyWork +import app.worktrack.core.model.TaskStatus +import app.worktrack.core.model.WorkDay +import app.worktrack.core.model.WorkTask +import java.time.LocalDate +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * How far ahead to look for the next day carrying work. A fortnight covers a + * weekend plus the longest run of public holidays the Afghan calendar produces. + */ +const val WORK_LOOKAHEAD_DAYS = 14L + +/** + * Today, plus the next day that actually has something on it. + * + * Skipping empty days is what makes this useful on a Thursday: the honest + * answer to "and after that?" is Saturday's work, not an empty Friday. A worker + * shown a blank Friday concludes he is not needed, and finds out otherwise when + * somebody rings him. + * + * The server is the authority on which days are weekends and holidays — it + * holds the company's calendar — so this deliberately does not try to + * reproduce that rule. It reproduces only the shape, which is all the phone + * needs to be right about while it is offline: the correct tasks on the + * correct dates. + * + * Pure, and separate from the repository, because it is the one piece of this + * feature that can be wrong in a way the worker acts on. + */ +fun selectMyWork( + today: LocalDate, + tasks: List, + lookaheadDays: Long = WORK_LOOKAHEAD_DAYS, +): MyWork { + val nextDate = (1..lookaheadDays) + .map { today.plusDays(it) } + .firstOrNull { date -> tasks.any { it.runsOn(date) } } + // Nothing scheduled at all: show a plain tomorrow rather than dropping + // the second card, so its absence never has to be explained. + ?: today.plusDays(1) + + return MyWork( + today = WorkDay(today, DayKind.WORKING, tasks.filter { it.runsOn(today) }), + next = WorkDay(nextDate, DayKind.WORKING, tasks.filter { it.runsOn(nextDate) }), + ) +} + +/** What this employee is on today and on the next day carrying work. */ +class ObserveMyWorkUseCase @Inject constructor( + private val workRepository: WorkRepository, + private val timeProvider: TimeProvider, +) { + operator fun invoke(): Flow { + val today = timeProvider.today() + return workRepository + .observeTasks(today, today.plusDays(WORK_LOOKAHEAD_DAYS)) + .map { tasks -> selectMyWork(today, tasks) } + } +} + +/** Report progress on your own task. */ +class SetTaskStatusUseCase @Inject constructor( + private val workRepository: WorkRepository, +) { + suspend operator fun invoke(taskId: String, status: TaskStatus): AppResult = + workRepository.setStatus(taskId, status) +} diff --git a/core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/attendance/EvaluateGeofenceUseCaseTest.kt b/core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/attendance/EvaluateGeofenceUseCaseTest.kt new file mode 100644 index 0000000..aa1566a --- /dev/null +++ b/core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/attendance/EvaluateGeofenceUseCaseTest.kt @@ -0,0 +1,105 @@ +package app.worktrack.core.domain.usecase.attendance + +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.model.AttendanceDay +import app.worktrack.core.model.AttendancePunch +import app.worktrack.core.model.Geofence +import app.worktrack.core.model.PunchCommand +import app.worktrack.core.model.RegularizationCommand +import app.worktrack.core.model.TodayAttendance +import java.time.Instant +import java.time.LocalDate +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class EvaluateGeofenceUseCaseTest { + + private class FakeAttendanceRepository( + private val fences: List, + ) : AttendanceRepository { + override fun observeToday(): Flow = emptyFlow() + override fun observeDays(from: LocalDate, to: LocalDate): Flow> = emptyFlow() + override fun observePunches(from: LocalDate, to: LocalDate): Flow> = emptyFlow() + override fun observeActiveGeofences(): Flow> = flowOf(fences) + override suspend fun punch(command: PunchCommand): AppResult = + error("not used in this test") + override suspend fun requestRegularization( + command: RegularizationCommand, + ): AppResult = error("not used in this test") + override suspend fun refresh(from: LocalDate, to: LocalDate): AppResult = + AppResult.success(Unit) + } + + private fun fence(id: String, lat: Double, lng: Double, radius: Int) = Geofence( + id = id, + companyId = "c1", + branchId = "b1", + name = "HQ", + latitude = lat, + longitude = lng, + radiusMeters = radius, + active = true, + updatedAt = Instant.EPOCH, + ) + + @Test + fun `no fences configured permits punching anywhere`() = runTest { + val useCase = EvaluateGeofenceUseCase(FakeAttendanceRepository(emptyList())) + val result = useCase(34.5553, 69.2075, accuracyMeters = 10f) + assertFalse(result.fencesConfigured) + assertFalse(result.insideFence) + } + + @Test + fun `inside radius is detected`() = runTest { + val useCase = EvaluateGeofenceUseCase( + FakeAttendanceRepository(listOf(fence("f1", 34.5553, 69.2075, radius = 150))), + ) + // ~55m east of the fence center at this latitude. + val result = useCase(34.5553, 69.2081, accuracyMeters = 5f) + assertTrue(result.insideFence) + assertEquals("f1", result.nearestFence?.id) + } + + @Test + fun `far outside radius is rejected`() = runTest { + val useCase = EvaluateGeofenceUseCase( + FakeAttendanceRepository(listOf(fence("f1", 34.5553, 69.2075, radius = 100))), + ) + // ~1.1km away. + val result = useCase(34.5553, 69.2195, accuracyMeters = 5f) + assertFalse(result.insideFence) + } + + @Test + fun `gps accuracy is credited toward the fence`() = runTest { + val useCase = EvaluateGeofenceUseCase( + FakeAttendanceRepository(listOf(fence("f1", 34.5553, 69.2075, radius = 100))), + ) + // ~155m out, but a 60m error circle overlaps the fence. + val result = useCase(34.5553, 69.2092, accuracyMeters = 60f) + assertTrue(result.insideFence) + } + + @Test + fun `nearest of multiple fences wins`() = runTest { + val useCase = EvaluateGeofenceUseCase( + FakeAttendanceRepository( + listOf( + fence("far", 34.60, 69.30, radius = 100), + fence("near", 34.5553, 69.2075, radius = 100), + ), + ), + ) + val result = useCase(34.5554, 69.2076, accuracyMeters = 5f) + assertEquals("near", result.nearestFence?.id) + assertTrue(result.insideFence) + } +} diff --git a/core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/leave/ApplyLeaveUseCaseTest.kt b/core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/leave/ApplyLeaveUseCaseTest.kt new file mode 100644 index 0000000..f2bbc01 --- /dev/null +++ b/core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/leave/ApplyLeaveUseCaseTest.kt @@ -0,0 +1,60 @@ +package app.worktrack.core.domain.usecase.leave + +import app.worktrack.core.model.LeaveApplication +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Test + +class ApplyLeaveUseCaseTest { + + private fun application( + start: LocalDate, + end: LocalDate, + startHalf: Boolean = false, + endHalf: Boolean = false, + ) = LeaveApplication( + leaveTypeId = "lt-1", + startDate = start, + endDate = end, + startHalfDay = startHalf, + endHalfDay = endHalf, + reason = "Family event", + ) + + @Test + fun `full single day counts as one`() { + val app = application(LocalDate.of(2026, 7, 20), LocalDate.of(2026, 7, 20)) + assertEquals(1.0, ApplyLeaveUseCase.calculateDays(app), 0.0) + } + + @Test + fun `half single day counts as half regardless of which flag`() { + val start = LocalDate.of(2026, 7, 20) + assertEquals(0.5, ApplyLeaveUseCase.calculateDays(application(start, start, startHalf = true)), 0.0) + assertEquals(0.5, ApplyLeaveUseCase.calculateDays(application(start, start, endHalf = true)), 0.0) + assertEquals(0.5, ApplyLeaveUseCase.calculateDays(application(start, start, startHalf = true, endHalf = true)), 0.0) + } + + @Test + fun `inclusive multi day range`() { + val app = application(LocalDate.of(2026, 7, 20), LocalDate.of(2026, 7, 24)) + assertEquals(5.0, ApplyLeaveUseCase.calculateDays(app), 0.0) + } + + @Test + fun `half days trim both ends of a range`() { + val app = application( + LocalDate.of(2026, 7, 20), + LocalDate.of(2026, 7, 24), + startHalf = true, + endHalf = true, + ) + assertEquals(4.0, ApplyLeaveUseCase.calculateDays(app), 0.0) + } + + @Test + fun `inverted range yields zero`() { + val app = application(LocalDate.of(2026, 7, 24), LocalDate.of(2026, 7, 20)) + assertEquals(0.0, ApplyLeaveUseCase.calculateDays(app), 0.0) + } +} diff --git a/core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/work/SelectMyWorkTest.kt b/core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/work/SelectMyWorkTest.kt new file mode 100644 index 0000000..555ac7a --- /dev/null +++ b/core/domain/src/test/kotlin/app/worktrack/core/domain/usecase/work/SelectMyWorkTest.kt @@ -0,0 +1,127 @@ +package app.worktrack.core.domain.usecase.work + +import app.worktrack.core.model.TaskPriority +import app.worktrack.core.model.TaskStatus +import app.worktrack.core.model.WorkTask +import java.time.Instant +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Which two days the phone shows. + * + * This is the one piece of the feature a worker acts on directly: he reads the + * second card, and goes where it says. Getting it wrong on a Thursday sends + * somebody to the wrong part of the site on Saturday. + */ +class SelectMyWorkTest { + + // 2026-09-10 is a Thursday; Friday is the weekend in Afghanistan. + private val thursday = LocalDate.parse("2026-09-10") + private val friday = LocalDate.parse("2026-09-11") + private val saturday = LocalDate.parse("2026-09-12") + + private fun task( + id: String, + start: String, + end: String = start, + names: List = listOf("Ali Rahimi"), + ) = WorkTask( + id = id, + projectId = "p1", + projectName = "Darulaman Tower", + title = "Task $id", + detail = null, + location = null, + startDate = LocalDate.parse(start), + endDate = LocalDate.parse(end), + status = TaskStatus.PLANNED, + priority = TaskPriority.NORMAL, + teamName = null, + assigneeNames = names, + updatedAt = Instant.EPOCH, + ) + + @Test + fun `skips the empty weekend and answers with Saturday's work`() { + val work = selectMyWork(thursday, listOf(task("a", "2026-09-12"))) + + assertEquals(saturday, work.next!!.date) + assertEquals(listOf("a"), work.next!!.tasks.map { it.id }) + } + + @Test + fun `does not offer an empty Friday just because it is tomorrow`() { + val work = selectMyWork(thursday, listOf(task("a", "2026-09-12"))) + assertFalse(work.next!!.date == friday) + } + + @Test + fun `uses tomorrow when tomorrow is the day that has work`() { + val work = selectMyWork(thursday, listOf(task("a", "2026-09-11"))) + assertEquals(friday, work.next!!.date) + } + + @Test + fun `shows today's work on today`() { + val work = selectMyWork(thursday, listOf(task("a", "2026-09-10"), task("b", "2026-09-12"))) + + assertEquals(listOf("a"), work.today.tasks.map { it.id }) + assertEquals(thursday, work.today.date) + } + + @Test + fun `a multi-day job appears on both days`() { + // The job an employee is in the middle of is the one most likely to be + // dropped by a naive "starts today" filter. + val work = selectMyWork(thursday, listOf(task("a", "2026-09-08", "2026-09-14"))) + + assertEquals(listOf("a"), work.today.tasks.map { it.id }) + assertEquals(listOf("a"), work.next!!.tasks.map { it.id }) + // And the next day is simply tomorrow, because the job runs through it. + assertEquals(friday, work.next!!.date) + } + + @Test + fun `still shows a second day when nothing at all is assigned`() { + // Dropping the card would leave the worker wondering whether the app + // failed to load rather than whether he has anything on. + val work = selectMyWork(thursday, emptyList()) + + assertTrue(work.today.tasks.isEmpty()) + assertEquals(friday, work.next!!.date) + assertTrue(work.next!!.tasks.isEmpty()) + } + + @Test + fun `ignores work beyond the fortnight it looks ahead`() { + val work = selectMyWork(thursday, listOf(task("a", "2026-10-20"))) + assertEquals(friday, work.next!!.date) + assertTrue(work.next!!.tasks.isEmpty()) + } + + @Test + fun `does not resurrect work that finished yesterday`() { + val work = selectMyWork(thursday, listOf(task("a", "2026-09-01", "2026-09-09"))) + assertTrue(work.today.tasks.isEmpty()) + assertTrue(work.next!!.tasks.isEmpty()) + } + + @Test + fun `a job with two names on it is team work`() { + assertTrue(task("a", "2026-09-10", names = listOf("Ali", "Omar")).isTeamWork) + assertFalse(task("a", "2026-09-10", names = listOf("Ali")).isTeamWork) + } + + @Test + fun `runsOn includes both ends of the span`() { + val t = task("a", "2026-09-10", "2026-09-12") + assertTrue(t.runsOn(thursday)) + assertTrue(t.runsOn(saturday)) + assertFalse(t.runsOn(LocalDate.parse("2026-09-09"))) + assertFalse(t.runsOn(LocalDate.parse("2026-09-13"))) + } +} diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts new file mode 100644 index 0000000..05d3c10 --- /dev/null +++ b/core/model/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + alias(libs.plugins.worktrack.jvm.library) +} diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Announcement.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Announcement.kt new file mode 100644 index 0000000..d6df37e --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Announcement.kt @@ -0,0 +1,17 @@ +package app.worktrack.core.model + +import java.time.Instant + +enum class AnnouncementPriority { NORMAL, IMPORTANT, URGENT } + +data class Announcement( + val id: String, + val companyId: String, + val title: String, + val body: String, + val priority: AnnouncementPriority, + val publishedAt: Instant, + val expiresAt: Instant?, + val createdByName: String?, + val updatedAt: Instant, +) diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Attendance.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Attendance.kt new file mode 100644 index 0000000..8d13c76 --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Attendance.kt @@ -0,0 +1,91 @@ +package app.worktrack.core.model + +import java.time.Instant +import java.time.LocalDate + +enum class PunchType { IN, OUT } + +enum class PunchMethod { GPS, QR, FACE, MANUAL, KIOSK } + +/** A single clock event. Append-only: punches are never edited or deleted. */ +data class AttendancePunch( + val id: String, + val companyId: String, + val employeeId: String, + val punchedAt: Instant, + val type: PunchType, + val method: PunchMethod, + val latitude: Double?, + val longitude: Double?, + val accuracyMeters: Float?, + val geofenceId: String?, + val insideFence: Boolean, + val note: String?, + val serverValidated: Boolean, + val invalidReason: String?, + val syncStatus: SyncStatus, +) + +enum class AttendanceDayStatus { PRESENT, ABSENT, HALF_DAY, LEAVE, HOLIDAY, WEEK_OFF, PENDING } + +/** Server-computed daily projection; the client never derives payroll-relevant minutes. */ +data class AttendanceDay( + val id: String, + val employeeId: String, + val date: LocalDate, + val shiftId: String?, + val firstInAt: Instant?, + val lastOutAt: Instant?, + val workedMinutes: Int, + val lateMinutes: Int, + val earlyOutMinutes: Int, + val overtimeMinutes: Int, + val status: AttendanceDayStatus, +) + +/** + * Input for the punch use case, built by the punch screen. Geofence fields are + * stamped by the use case after evaluation; the server re-validates regardless. + */ +data class PunchCommand( + val type: PunchType, + val method: PunchMethod, + val latitude: Double?, + val longitude: Double?, + val accuracyMeters: Float?, + val isMockLocation: Boolean, + val geofenceId: String? = null, + val insideFence: Boolean = false, + val kioskToken: String? = null, + val note: String? = null, + /** Base64 JPEG check-in selfie captured when photo-verified attendance is on. */ + val selfie: String? = null, + /** + * Server-signed proof of a face match, obtained from the verify endpoint. + * The server derives `faceVerified` from this; the client cannot assert it. + */ + val faceToken: String? = null, +) + +/** + * Employee-filed request to correct a day's clock-in/out. At least one of the + * two instants must be present. Filed offline-first; a manager approves it in + * the web portal and the corrected day flows back through the normal sync pull. + */ +data class RegularizationCommand( + val date: LocalDate, + val requestedInAt: Instant?, + val requestedOutAt: Instant?, + val reason: String, +) + +/** Live view of "where the user stands right now" for dashboard + punch screen. */ +data class TodayAttendance( + val date: LocalDate, + val clockedIn: Boolean, + val firstInAt: Instant?, + val lastPunchAt: Instant?, + val punchCount: Int, + val workedMinutesSoFar: Int, + val shift: Shift?, +) diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Employee.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Employee.kt new file mode 100644 index 0000000..da1e5d7 --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Employee.kt @@ -0,0 +1,86 @@ +package app.worktrack.core.model + +import java.time.Instant +import java.time.LocalDate + +enum class EmploymentType { FULL_TIME, PART_TIME, CONTRACT, INTERN } + +enum class EmployeeStatus { ACTIVE, ON_LEAVE, SUSPENDED, EXITED } + +/** Built-in platform roles; custom roles resolve to permission sets server-side. */ +enum class RoleCode { + SUPER_ADMIN, + COMPANY_ADMIN, + HR_ADMIN, + PAYROLL_ADMIN, + BRANCH_MANAGER, + TEAM_LEAD, + EMPLOYEE, + AUDITOR, + KIOSK, + ; + + companion object { + fun fromCode(code: String): RoleCode? = entries.firstOrNull { it.name == code } + } +} + +data class Employee( + val id: String, + val companyId: String, + val employeeCode: String, + val firstName: String, + val lastName: String, + val email: String, + val phone: String?, + val avatarUrl: String?, + val branchId: String?, + val departmentId: String?, + val positionId: String?, + val managerId: String?, + val employmentType: EmploymentType, + val joinDate: LocalDate, + val status: EmployeeStatus, + val updatedAt: Instant, +) { + val fullName: String get() = "$firstName $lastName".trim() +} + +/** + * The authenticated user's resolved context: identity plus tenant scoping and + * roles from Firebase custom claims, refreshed from GET /me. + */ +/** Company module toggles configured by the admin; unknown → enabled. */ +data class CompanyFeatures( + val shifts: Boolean = true, + val leave: Boolean = true, + val payroll: Boolean = true, + val regularization: Boolean = true, + val announcements: Boolean = true, + val geofencing: Boolean = true, + val qrKiosk: Boolean = true, + val faceRecognition: Boolean = true, +) + +data class UserSession( + val uid: String, + val companyId: String, + val employeeId: String, + val displayName: String, + val email: String, + val avatarUrl: String?, + val roles: Set, + val branchIds: List, + val companyName: String, + val features: CompanyFeatures = CompanyFeatures(), +) { + fun hasAnyRole(vararg candidates: RoleCode): Boolean = candidates.any { it in roles } + + val isApprover: Boolean + get() = hasAnyRole( + RoleCode.COMPANY_ADMIN, + RoleCode.HR_ADMIN, + RoleCode.BRANCH_MANAGER, + RoleCode.TEAM_LEAD, + ) +} diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Leave.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Leave.kt new file mode 100644 index 0000000..dd49005 --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Leave.kt @@ -0,0 +1,75 @@ +package app.worktrack.core.model + +import java.time.Instant +import java.time.LocalDate + +data class LeaveType( + val id: String, + val companyId: String, + val name: String, + val code: String, + val colorHex: String, + val isPaid: Boolean, + val requiresAttachment: Boolean, + val active: Boolean, + val updatedAt: Instant, +) + +data class LeaveBalance( + val id: String, + val employeeId: String, + val leaveTypeId: String, + val periodYear: Int, + val entitledDays: Double, + val accruedDays: Double, + val usedDays: Double, + val carriedOverDays: Double, + val pendingDays: Double, + val updatedAt: Instant, +) { + val availableDays: Double + get() = entitledDays + accruedDays + carriedOverDays - usedDays - pendingDays +} + +enum class LeaveStatus { DRAFT, PENDING, APPROVED, REJECTED, CANCELLED } + +data class LeaveRequest( + val id: String, + val companyId: String, + val employeeId: String, + val employeeName: String?, + val leaveTypeId: String, + val startDate: LocalDate, + val endDate: LocalDate, + val startHalfDay: Boolean, + val endHalfDay: Boolean, + val days: Double, + val reason: String, + val status: LeaveStatus, + val currentApproverId: String?, + val decidedAt: Instant?, + val decisionNote: String?, + val createdAt: Instant, + val updatedAt: Instant, + val syncStatus: SyncStatus, +) + +enum class ApprovalDecision { APPROVE, REJECT } + +/** Input for the apply-leave use case. */ +data class LeaveApplication( + val leaveTypeId: String, + val startDate: LocalDate, + val endDate: LocalDate, + val startHalfDay: Boolean, + val endHalfDay: Boolean, + val reason: String, +) + +data class Holiday( + val id: String, + val calendarId: String, + val date: LocalDate, + val name: String, + val isOptional: Boolean, +) diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Org.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Org.kt new file mode 100644 index 0000000..112d981 --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Org.kt @@ -0,0 +1,52 @@ +package app.worktrack.core.model + +import java.time.Instant + +data class Company( + val id: String, + val name: String, + val legalName: String?, + val timezone: String, + val currency: String, +) + +data class Branch( + val id: String, + val companyId: String, + val name: String, + val code: String, + val address: String?, + val latitude: Double?, + val longitude: Double?, + val radiusMeters: Int?, + val timezone: String, + val updatedAt: Instant, +) + +data class Geofence( + val id: String, + val companyId: String, + val branchId: String, + val name: String, + val latitude: Double, + val longitude: Double, + val radiusMeters: Int, + val active: Boolean, + val updatedAt: Instant, +) + +data class Department( + val id: String, + val companyId: String, + val branchId: String?, + val name: String, + val code: String, +) + +data class Position( + val id: String, + val companyId: String, + val title: String, + val code: String, + val level: Int?, +) diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Payroll.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Payroll.kt new file mode 100644 index 0000000..c7ef937 --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Payroll.kt @@ -0,0 +1,35 @@ +package app.worktrack.core.model + +import java.time.Instant + +enum class PayComponentType { EARNING, DEDUCTION, EMPLOYER_COST } + +enum class PayslipStatus { DRAFT, FINALIZED, PAID } + +data class PayslipLine( + val componentCode: String, + val componentName: String, + val type: PayComponentType, + val amount: Double, +) + +data class Payslip( + val id: String, + val companyId: String, + val runId: String, + val employeeId: String, + val periodYear: Int, + val periodMonth: Int, + val currency: String, + val gross: Double, + val totalDeductions: Double, + val net: Double, + val workedDays: Double, + val paidLeaveDays: Double, + val lopDays: Double, + val overtimeMinutes: Int, + val status: PayslipStatus, + val pdfUrl: String?, + val lines: List, + val updatedAt: Instant, +) diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Shift.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Shift.kt new file mode 100644 index 0000000..b7c6a38 --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Shift.kt @@ -0,0 +1,33 @@ +package app.worktrack.core.model + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime + +data class Shift( + val id: String, + val companyId: String, + val name: String, + val code: String, + val startTime: LocalTime, + val endTime: LocalTime, + val breakMinutes: Int, + val graceInMinutes: Int, + val graceOutMinutes: Int, + val isNightShift: Boolean, + val active: Boolean, + val updatedAt: Instant, +) + +enum class ShiftAssignmentSource { ROSTER, ROTATION, MANUAL, SWAP } + +data class ShiftAssignment( + val id: String, + val companyId: String, + val employeeId: String, + val shiftId: String, + val date: LocalDate, + val branchId: String?, + val source: ShiftAssignmentSource, + val updatedAt: Instant, +) diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Sync.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Sync.kt new file mode 100644 index 0000000..7eb53a7 --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Sync.kt @@ -0,0 +1,15 @@ +package app.worktrack.core.model + +import java.time.Instant + +/** Client-side replication status of a locally stored row. */ +enum class SyncStatus { SYNCED, PENDING, FAILED } + +/** Aggregate health of the sync engine, surfaced in Profile and debug UIs. */ +data class SyncState( + val isSyncing: Boolean, + val pendingOperations: Int, + val failedOperations: Int, + val lastSuccessAt: Instant?, + val lastError: String?, +) diff --git a/core/model/src/main/kotlin/app/worktrack/core/model/Work.kt b/core/model/src/main/kotlin/app/worktrack/core/model/Work.kt new file mode 100644 index 0000000..304ee1b --- /dev/null +++ b/core/model/src/main/kotlin/app/worktrack/core/model/Work.kt @@ -0,0 +1,73 @@ +package app.worktrack.core.model + +import java.time.Instant +import java.time.LocalDate + +/** + * What the employee is meant to be doing, and on which part of the job. + * + * Attendance answers "was I here". This answers the question a worker actually + * asks on the way in: which part of the company's work am I on today. + */ + +enum class TaskStatus { PLANNED, IN_PROGRESS, DONE, BLOCKED } + +enum class TaskPriority { LOW, NORMAL, HIGH } + +/** A contract, a site, a phase — the thing a task belongs to. */ +data class Project( + val id: String, + val name: String, + val code: String, + val status: String, + val updatedAt: Instant, +) + +/** + * One piece of work over a date range. + * + * [assigneeNames] is carried rather than looked up: the phone only ever + * replicates its own employee row, so without the names a team task would show + * a list of ids the worker cannot read. + */ +data class WorkTask( + val id: String, + val projectId: String, + val projectName: String, + val title: String, + val detail: String?, + val location: String?, + val startDate: LocalDate, + val endDate: LocalDate, + val status: TaskStatus, + val priority: TaskPriority, + val teamName: String?, + val assigneeNames: List, + val updatedAt: Instant, +) { + /** True when more than one person is on it — a crew job, not a solo one. */ + val isTeamWork: Boolean get() = assigneeNames.size > 1 + + fun runsOn(date: LocalDate): Boolean = !date.isBefore(startDate) && !date.isAfter(endDate) +} + +/** Why a day is empty, when it is. */ +enum class DayKind { WORKING, WEEKEND, HOLIDAY } + +data class WorkDay( + val date: LocalDate, + val kind: DayKind, + val tasks: List, +) + +/** + * Today and the next day the employee is actually expected in. + * + * [next] is deliberately not "tomorrow": asked on a Thursday it is Saturday, + * because Friday is the weekend here and an empty Friday would read as having + * nothing on. + */ +data class MyWork( + val today: WorkDay, + val next: WorkDay?, +) diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts new file mode 100644 index 0000000..e7f511e --- /dev/null +++ b/core/network/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + alias(libs.plugins.worktrack.android.library) + alias(libs.plugins.worktrack.android.hilt) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "app.worktrack.core.network" +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.model) + + implementation(libs.kotlinx.coroutines.android) + api(libs.kotlinx.serialization.json) + api(libs.retrofit.core) + implementation(libs.retrofit.kotlinx.serialization) + implementation(libs.okhttp.core) + implementation(libs.okhttp.logging) +} diff --git a/core/network/src/main/AndroidManifest.xml b/core/network/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9967376 --- /dev/null +++ b/core/network/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/ApiCall.kt b/core/network/src/main/kotlin/app/worktrack/core/network/ApiCall.kt new file mode 100644 index 0000000..20e22dd --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/ApiCall.kt @@ -0,0 +1,60 @@ +package app.worktrack.core.network + +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.network.dto.ProblemDto +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import retrofit2.HttpException + +private val problemJson = Json { ignoreUnknownKeys = true } + +/** + * Runs one API call and converts transport/protocol failures into the app-wide + * [AppError] taxonomy. The only place HttpException/IOException are handled. + */ +suspend fun apiCall(block: suspend () -> T): AppResult = try { + AppResult.success(block()) +} catch (e: CancellationException) { + throw e +} catch (e: HttpException) { + AppResult.failure(e.toAppError()) +} catch (e: IOException) { + AppResult.failure(AppError.Network) +} catch (e: SerializationException) { + AppResult.failure(AppError.Unexpected(e)) +} + +private fun HttpException.toAppError(): AppError { + val problem = try { + response()?.errorBody()?.string() + ?.takeIf { it.isNotBlank() } + ?.let { problemJson.decodeFromString(it) } + } catch (_: SerializationException) { + null + } + + return when (code()) { + 401 -> AppError.Unauthenticated + 403 -> AppError.PermissionDenied + 404 -> AppError.NotFound + 400, 422 -> + if (problem?.code != null && problem.fieldErrors.isEmpty()) { + AppError.Business(problem.code, problem.detail ?: problem.title ?: "Request rejected") + } else { + AppError.Validation( + message = problem?.detail ?: problem?.title ?: "Invalid request", + fieldErrors = problem?.fieldErrors.orEmpty(), + ) + } + + 409 -> AppError.Business( + code = problem?.code ?: "CONFLICT", + message = problem?.detail ?: "The resource changed on the server", + ) + + else -> AppError.Http(code(), problem?.code, problem?.detail ?: problem?.title) + } +} diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/NetworkMonitor.kt b/core/network/src/main/kotlin/app/worktrack/core/network/NetworkMonitor.kt new file mode 100644 index 0000000..89b10b6 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/NetworkMonitor.kt @@ -0,0 +1,61 @@ +package app.worktrack.core.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged + +interface NetworkMonitor { + val isOnline: Flow +} + +@Singleton +class ConnectivityNetworkMonitor @Inject constructor( + @ApplicationContext private val context: Context, +) : NetworkMonitor { + + override val isOnline: Flow = callbackFlow { + val manager = context.getSystemService(ConnectivityManager::class.java) + + fun currentlyOnline(): Boolean { + val network = manager.activeNetwork ?: return false + val caps = manager.getNetworkCapabilities(network) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } + + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(true) + } + + override fun onLost(network: Network) { + trySend(currentlyOnline()) + } + + override fun onCapabilitiesChanged( + network: Network, + capabilities: NetworkCapabilities, + ) { + trySend(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) + } + } + + manager.registerNetworkCallback( + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build(), + callback, + ) + trySend(currentlyOnline()) + + awaitClose { manager.unregisterNetworkCallback(callback) } + }.distinctUntilChanged() +} diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/WorkTrackApi.kt b/core/network/src/main/kotlin/app/worktrack/core/network/WorkTrackApi.kt new file mode 100644 index 0000000..ab18710 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/WorkTrackApi.kt @@ -0,0 +1,100 @@ +package app.worktrack.core.network + +import app.worktrack.core.network.dto.AnnouncementDto +import app.worktrack.core.network.dto.ApiEnvelope +import app.worktrack.core.network.dto.AttendanceDayDto +import app.worktrack.core.network.dto.FaceEmbeddingDto +import app.worktrack.core.network.dto.FaceEnrollResultDto +import app.worktrack.core.network.dto.FaceVerifyResultDto +import app.worktrack.core.network.dto.LeaveDecisionDto +import app.worktrack.core.network.dto.LeaveRequestDto +import app.worktrack.core.network.dto.MeDto +import app.worktrack.core.network.dto.PayslipDto +import app.worktrack.core.network.dto.MyWorkDto +import app.worktrack.core.network.dto.SyncPullResponseDto +import app.worktrack.core.network.dto.TaskStatusDto +import app.worktrack.core.network.dto.WorkTaskDto +import app.worktrack.core.network.dto.SyncPushRequestDto +import app.worktrack.core.network.dto.SyncPushResponseDto +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * WorkTrack REST API v1. Offline-capable mutations flow through POST /sync/push + * (batched outbox operations); the endpoints here are session resolution, + * windowed reads, and online-only decisions. + */ +interface WorkTrackApi { + + @GET("me") + suspend fun me(): ApiEnvelope + + /** Enroll the caller's own face (on-device embedding, never a photo). */ + @POST("me/face/enroll") + suspend fun enrollFace(@Body body: FaceEmbeddingDto): ApiEnvelope + + /** Verify a face check-in against the caller's enrolled embedding. */ + @POST("attendance/face/verify") + suspend fun verifyFace(@Body body: FaceEmbeddingDto): ApiEnvelope + + @GET("attendance/days") + suspend fun attendanceDays( + @Query("from") from: String, // ISO date + @Query("to") to: String, + ): ApiEnvelope> + + @GET("payslips") + suspend fun payslips( + @Query("year") year: Int, + ): ApiEnvelope> + + @GET("announcements") + suspend fun announcements(): ApiEnvelope> + + /** + * What this employee is on today and next. Takes no date: the server knows + * what day it is where the company is, and a phone set to another timezone + * would otherwise ask about the wrong one. + */ + @GET("work/mine") + suspend fun myWork(): ApiEnvelope + + /** Report progress on one of your own tasks. */ + @POST("work/tasks/{id}/status") + suspend fun setTaskStatus( + @Path("id") taskId: String, + @Body body: TaskStatusDto, + ): ApiEnvelope + + @GET("leave/requests") + suspend fun leaveRequests( + @Query("scope") scope: String, // mine | approvals + ): ApiEnvelope> + + @POST("leave/requests/{id}/decide") + suspend fun decideLeaveRequest( + @Path("id") requestId: String, + @Body body: LeaveDecisionDto, + @Header("Idempotency-Key") idempotencyKey: String, + ): ApiEnvelope + + @POST("leave/requests/{id}/cancel") + suspend fun cancelLeaveRequest( + @Path("id") requestId: String, + @Header("Idempotency-Key") idempotencyKey: String, + ): ApiEnvelope + + @POST("sync/push") + suspend fun syncPush(@Body body: SyncPushRequestDto): ApiEnvelope + + @GET("sync/pull") + suspend fun syncPull( + @Query("type") resourceType: String, + @Query("cursor") cursor: String?, + @Query("limit") limit: Int = 500, + ): ApiEnvelope +} diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/auth/AuthTokenProvider.kt b/core/network/src/main/kotlin/app/worktrack/core/network/auth/AuthTokenProvider.kt new file mode 100644 index 0000000..9a7f6ee --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/auth/AuthTokenProvider.kt @@ -0,0 +1,14 @@ +package app.worktrack.core.network.auth + +/** + * Supplies the bearer token for API calls. Implemented over Firebase Auth in + * :core:data so that :core:network stays free of the Firebase dependency. + */ +interface AuthTokenProvider { + + /** + * Returns a currently valid ID token, refreshing if needed, or null when + * signed out. Must be safe to call from any thread. + */ + suspend fun idToken(forceRefresh: Boolean = false): String? +} diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/device/DeviceIdProvider.kt b/core/network/src/main/kotlin/app/worktrack/core/network/device/DeviceIdProvider.kt new file mode 100644 index 0000000..7581c22 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/device/DeviceIdProvider.kt @@ -0,0 +1,12 @@ +package app.worktrack.core.network.device + +/** + * Supplies this installation's licence device id. Implemented over DataStore in + * :core:data so that :core:network stays free of the persistence dependency — + * the same arrangement as AuthTokenProvider. + */ +interface DeviceIdProvider { + + /** Stable for the life of the install. Must be safe to call from any thread. */ + suspend fun deviceId(): String +} diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/app/worktrack/core/network/di/NetworkModule.kt new file mode 100644 index 0000000..9320a0c --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/di/NetworkModule.kt @@ -0,0 +1,86 @@ +package app.worktrack.core.network.di + +import android.content.Context +import android.content.pm.ApplicationInfo +import app.worktrack.core.network.ConnectivityNetworkMonitor +import app.worktrack.core.network.NetworkMonitor +import app.worktrack.core.network.WorkTrackApi +import app.worktrack.core.network.interceptor.AuthInterceptor +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.util.concurrent.TimeUnit +import javax.inject.Singleton +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory + +/** Base URL for the versioned API; supplied by the app module per build variant. */ +/** + * Where this build talks to. [useEmulators] is carried alongside the URL so + * failures can say something useful: a debug build that cannot reach the local + * emulator looks identical to a real outage unless the code knows which it is. + */ +data class ApiConfig(val baseUrl: String, val useEmulators: Boolean = false) + +@Module +@InstallIn(SingletonComponent::class) +internal interface NetworkBindings { + @Binds + fun bindNetworkMonitor(impl: ConnectivityNetworkMonitor): NetworkMonitor +} + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideJson(): Json = Json { + ignoreUnknownKeys = true // additive API evolution must not break old clients + explicitNulls = false + coerceInputValues = true + } + + @Provides + @Singleton + fun provideOkHttpClient( + @ApplicationContext context: Context, + authInterceptor: AuthInterceptor, + ): OkHttpClient { + val builder = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .addInterceptor(authInterceptor) + + val debuggable = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 + if (debuggable) { + // BASIC only: request lines are useful in development, bodies may hold PII. + builder.addInterceptor( + HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }, + ) + } + return builder.build() + } + + @Provides + @Singleton + fun provideRetrofit(config: ApiConfig, client: OkHttpClient, json: Json): Retrofit = + Retrofit.Builder() + .baseUrl(config.baseUrl) + .client(client) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + + @Provides + @Singleton + fun provideWorkTrackApi(retrofit: Retrofit): WorkTrackApi = + retrofit.create(WorkTrackApi::class.java) +} diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/AttendanceDtos.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/AttendanceDtos.kt new file mode 100644 index 0000000..d434976 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/AttendanceDtos.kt @@ -0,0 +1,62 @@ +package app.worktrack.core.network.dto + +import app.worktrack.core.network.serializer.InstantSerializer +import app.worktrack.core.network.serializer.LocalDateSerializer +import java.time.Instant +import java.time.LocalDate +import kotlinx.serialization.Serializable + +/** Client -> server punch payload (also the outbox payload for punch ops). */ +@Serializable +data class PunchCreateDto( + val id: String, // client-generated ULID; doubles as the idempotency scope + @Serializable(InstantSerializer::class) val punchedAt: Instant, + val type: String, + val method: String, + val latitude: Double? = null, + val longitude: Double? = null, + val accuracyMeters: Float? = null, + val geofenceId: String? = null, + val insideFence: Boolean = false, + val kioskToken: String? = null, + val note: String? = null, + /** Optional check-in selfie (small base64 JPEG) for photo-verified attendance. */ + val selfie: String? = null, + /** Signed proof of a face match; the server derives `faceVerified` from it. */ + val faceToken: String? = null, +) + +@Serializable +data class PunchDto( + val id: String, + val companyId: String, + val employeeId: String, + @Serializable(InstantSerializer::class) val punchedAt: Instant, + val type: String, + val method: String, + val latitude: Double? = null, + val longitude: Double? = null, + val accuracyMeters: Float? = null, + val geofenceId: String? = null, + val insideFence: Boolean = false, + val note: String? = null, + val serverValidated: Boolean = false, + val invalidReason: String? = null, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +@Serializable +data class AttendanceDayDto( + val id: String, + val employeeId: String, + @Serializable(LocalDateSerializer::class) val date: LocalDate, + val shiftId: String? = null, + @Serializable(InstantSerializer::class) val firstInAt: Instant? = null, + @Serializable(InstantSerializer::class) val lastOutAt: Instant? = null, + val workedMinutes: Int = 0, + val lateMinutes: Int = 0, + val earlyOutMinutes: Int = 0, + val overtimeMinutes: Int = 0, + val status: String, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/Envelope.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/Envelope.kt new file mode 100644 index 0000000..706657f --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/Envelope.kt @@ -0,0 +1,27 @@ +package app.worktrack.core.network.dto + +import kotlinx.serialization.Serializable + +/** Standard success envelope: { "data": ..., "meta": { "cursor": ... } }. */ +@Serializable +data class ApiEnvelope( + val data: T, + val meta: ApiMeta? = null, +) + +@Serializable +data class ApiMeta( + val cursor: String? = null, + val hasMore: Boolean = false, +) + +/** RFC 7807 problem+json error body produced by the API. */ +@Serializable +data class ProblemDto( + val type: String? = null, + val title: String? = null, + val status: Int? = null, + val code: String? = null, + val detail: String? = null, + val fieldErrors: Map = emptyMap(), +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/FaceDtos.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/FaceDtos.kt new file mode 100644 index 0000000..6b25e1c --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/FaceDtos.kt @@ -0,0 +1,24 @@ +package app.worktrack.core.network.dto + +import kotlinx.serialization.Serializable + +/** On-device face embedding sent for enrollment or verification (never a photo). */ +@Serializable +data class FaceEmbeddingDto( + val embedding: List, +) + +@Serializable +data class FaceEnrollResultDto( + val faceEnrolled: Boolean = true, +) + +@Serializable +data class FaceVerifyResultDto( + val match: Boolean, + val similarity: Float, + val threshold: Float, + val enrolled: Boolean, + /** Signed proof of the match; presented with the punch. Null when no match. */ + val token: String? = null, +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/LeaveDtos.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/LeaveDtos.kt new file mode 100644 index 0000000..3896682 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/LeaveDtos.kt @@ -0,0 +1,73 @@ +package app.worktrack.core.network.dto + +import app.worktrack.core.network.serializer.InstantSerializer +import app.worktrack.core.network.serializer.LocalDateSerializer +import java.time.Instant +import java.time.LocalDate +import kotlinx.serialization.Serializable + +@Serializable +data class LeaveTypeDto( + val id: String, + val companyId: String, + val name: String, + val code: String, + val colorHex: String = "#607D8B", + val isPaid: Boolean = true, + val requiresAttachment: Boolean = false, + val active: Boolean = true, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +@Serializable +data class LeaveBalanceDto( + val id: String, + val employeeId: String, + val leaveTypeId: String, + val periodYear: Int, + val entitledDays: Double = 0.0, + val accruedDays: Double = 0.0, + val usedDays: Double = 0.0, + val carriedOverDays: Double = 0.0, + val pendingDays: Double = 0.0, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +/** Client -> server leave request payload (also the outbox payload). */ +@Serializable +data class LeaveRequestCreateDto( + val id: String, // client-generated ULID + val leaveTypeId: String, + @Serializable(LocalDateSerializer::class) val startDate: LocalDate, + @Serializable(LocalDateSerializer::class) val endDate: LocalDate, + val startHalfDay: Boolean = false, + val endHalfDay: Boolean = false, + val reason: String, +) + +@Serializable +data class LeaveRequestDto( + val id: String, + val companyId: String, + val employeeId: String, + val employeeName: String? = null, + val leaveTypeId: String, + @Serializable(LocalDateSerializer::class) val startDate: LocalDate, + @Serializable(LocalDateSerializer::class) val endDate: LocalDate, + val startHalfDay: Boolean = false, + val endHalfDay: Boolean = false, + val days: Double, + val reason: String, + val status: String, + val currentApproverId: String? = null, + @Serializable(InstantSerializer::class) val decidedAt: Instant? = null, + val decisionNote: String? = null, + @Serializable(InstantSerializer::class) val createdAt: Instant, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +@Serializable +data class LeaveDecisionDto( + val decision: String, // APPROVE | REJECT + val note: String? = null, +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/OrgDtos.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/OrgDtos.kt new file mode 100644 index 0000000..d654742 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/OrgDtos.kt @@ -0,0 +1,82 @@ +package app.worktrack.core.network.dto + +import app.worktrack.core.network.serializer.InstantSerializer +import app.worktrack.core.network.serializer.LocalDateSerializer +import java.time.Instant +import java.time.LocalDate +import kotlinx.serialization.Serializable + +@Serializable +data class BranchDto( + val id: String, + val companyId: String, + val name: String, + val code: String, + val address: String? = null, + val latitude: Double? = null, + val longitude: Double? = null, + val radiusMeters: Int? = null, + val timezone: String, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +@Serializable +data class GeofenceDto( + val id: String, + val companyId: String, + val branchId: String, + val name: String, + val latitude: Double, + val longitude: Double, + val radiusMeters: Int, + val active: Boolean, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +@Serializable +data class EmployeeDto( + val id: String, + val companyId: String, + val employeeCode: String, + val firstName: String, + val lastName: String, + val email: String, + val phone: String? = null, + val avatarUrl: String? = null, + val branchId: String? = null, + val departmentId: String? = null, + val positionId: String? = null, + val managerId: String? = null, + val employmentType: String, + @Serializable(LocalDateSerializer::class) val joinDate: LocalDate, + val status: String, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +@Serializable +data class ShiftDto( + val id: String, + val companyId: String, + val name: String, + val code: String, + val startTime: String, // "HH:mm" + val endTime: String, + val breakMinutes: Int, + val graceInMinutes: Int, + val graceOutMinutes: Int, + val isNightShift: Boolean, + val active: Boolean, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +@Serializable +data class ShiftAssignmentDto( + val id: String, + val companyId: String, + val employeeId: String, + val shiftId: String, + @Serializable(LocalDateSerializer::class) val date: LocalDate, + val branchId: String? = null, + val source: String, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/PayrollDtos.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/PayrollDtos.kt new file mode 100644 index 0000000..1baa8c9 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/PayrollDtos.kt @@ -0,0 +1,35 @@ +package app.worktrack.core.network.dto + +import app.worktrack.core.network.serializer.InstantSerializer +import java.time.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class PayslipLineDto( + val componentCode: String, + val componentName: String, + val type: String, + val amount: Double, +) + +@Serializable +data class PayslipDto( + val id: String, + val companyId: String, + val runId: String, + val employeeId: String, + val periodYear: Int, + val periodMonth: Int, + val currency: String, + val gross: Double, + val totalDeductions: Double, + val net: Double, + val workedDays: Double = 0.0, + val paidLeaveDays: Double = 0.0, + val lopDays: Double = 0.0, + val overtimeMinutes: Int = 0, + val status: String, + val pdfUrl: String? = null, + val lines: List = emptyList(), + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/PlatformDtos.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/PlatformDtos.kt new file mode 100644 index 0000000..f9462f7 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/PlatformDtos.kt @@ -0,0 +1,59 @@ +package app.worktrack.core.network.dto + +import app.worktrack.core.network.serializer.InstantSerializer +import java.time.Instant +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class AnnouncementDto( + val id: String, + val companyId: String, + val title: String, + val body: String, + val priority: String = "NORMAL", + @Serializable(InstantSerializer::class) val publishedAt: Instant, + @Serializable(InstantSerializer::class) val expiresAt: Instant? = null, + val createdByName: String? = null, + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +/** One queued mutation from the client outbox. */ +@Serializable +data class SyncOpDto( + val opId: String, + val opType: String, // CREATE | UPDATE | DELETE + val resourceType: String, // punches | leaveRequests | ... + val resourceId: String, + val idempotencyKey: String, + val payload: JsonObject, +) + +@Serializable +data class SyncPushRequestDto( + val ops: List, +) + +/** Per-op outcome; APPLIED covers idempotent replays of already-applied ops. */ +@Serializable +data class SyncOpResultDto( + val opId: String, + val status: String, // APPLIED | REJECTED + val errorCode: String? = null, + val message: String? = null, + val resource: JsonObject? = null, +) + +@Serializable +data class SyncPushResponseDto( + val results: List, +) + +/** Delta page for one resource type. Items are raw documents mapped per type. */ +@Serializable +data class SyncPullResponseDto( + val resourceType: String, + val items: List = emptyList(), + val nextCursor: String? = null, + val hasMore: Boolean = false, +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/RegularizationDtos.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/RegularizationDtos.kt new file mode 100644 index 0000000..69b84df --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/RegularizationDtos.kt @@ -0,0 +1,17 @@ +package app.worktrack.core.network.dto + +import app.worktrack.core.network.serializer.InstantSerializer +import app.worktrack.core.network.serializer.LocalDateSerializer +import java.time.Instant +import java.time.LocalDate +import kotlinx.serialization.Serializable + +/** Client -> server attendance-correction payload (also the outbox payload). */ +@Serializable +data class RegularizationCreateDto( + val id: String, // client-generated ULID + @Serializable(LocalDateSerializer::class) val date: LocalDate, + @Serializable(InstantSerializer::class) val requestedInAt: Instant? = null, + @Serializable(InstantSerializer::class) val requestedOutAt: Instant? = null, + val reason: String, +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/SessionDtos.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/SessionDtos.kt new file mode 100644 index 0000000..9d1cdd1 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/SessionDtos.kt @@ -0,0 +1,31 @@ +package app.worktrack.core.network.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class MeDto( + val uid: String, + val companyId: String, + val companyName: String, + val employeeId: String, + val displayName: String, + val email: String, + val avatarUrl: String? = null, + val roles: List = emptyList(), + val branchIds: List = emptyList(), + val features: MeFeaturesDto = MeFeaturesDto(), + val faceEnrolled: Boolean = false, +) + +/** Company module toggles (defaults on so older servers don't hide anything). */ +@Serializable +data class MeFeaturesDto( + val shifts: Boolean = true, + val leave: Boolean = true, + val payroll: Boolean = true, + val regularization: Boolean = true, + val announcements: Boolean = true, + val geofencing: Boolean = true, + val qrKiosk: Boolean = true, + val faceRecognition: Boolean = true, +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/dto/WorkDtos.kt b/core/network/src/main/kotlin/app/worktrack/core/network/dto/WorkDtos.kt new file mode 100644 index 0000000..60cd7be --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/dto/WorkDtos.kt @@ -0,0 +1,52 @@ +package app.worktrack.core.network.dto + +import app.worktrack.core.network.serializer.InstantSerializer +import java.time.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class ProjectDto( + val id: String, + val name: String, + val code: String, + val status: String = "ACTIVE", + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +@Serializable +data class WorkTaskDto( + val id: String, + val projectId: String, + val projectName: String = "", + val title: String, + val detail: String? = null, + val location: String? = null, + /** Plain calendar dates (YYYY-MM-DD); a task is scheduled, not timestamped. */ + val startDate: String, + val endDate: String, + val status: String = "PLANNED", + val priority: String = "NORMAL", + val teamName: String? = null, + val assigneeNames: List = emptyList(), + @Serializable(InstantSerializer::class) val updatedAt: Instant, +) + +@Serializable +data class WorkDayDto( + val date: String, + val kind: String = "WORKING", + val tasks: List = emptyList(), +) + +@Serializable +data class MyWorkDto( + val today: WorkDayDto, + val next: WorkDayDto? = null, +) + +/** Reporting progress on your own work. */ +@Serializable +data class TaskStatusDto( + val status: String, + val note: String? = null, +) diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/interceptor/AuthInterceptor.kt b/core/network/src/main/kotlin/app/worktrack/core/network/interceptor/AuthInterceptor.kt new file mode 100644 index 0000000..5f32ebd --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/interceptor/AuthInterceptor.kt @@ -0,0 +1,53 @@ +package app.worktrack.core.network.interceptor + +import app.worktrack.core.network.auth.AuthTokenProvider +import app.worktrack.core.network.device.DeviceIdProvider +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Attaches the Firebase ID token plus client metadata headers. runBlocking is + * safe here: OkHttp interceptors always execute on OkHttp's dispatcher threads, + * and the Firebase SDK serves cached tokens without I/O in the common case. + */ +@Singleton +class AuthInterceptor @Inject constructor( + private val tokenProvider: AuthTokenProvider, + private val deviceIdProvider: DeviceIdProvider, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val original = chain.request() + val token = runBlocking { tokenProvider.idToken() } + // Identifies which licensed device this call comes from. Read from the + // local store, so it costs nothing after the first call. + val deviceId = runBlocking { deviceIdProvider.deviceId() } + + val request = original.newBuilder() + .apply { token?.let { header("Authorization", "Bearer $it") } } + .header("X-Client", "worktrack-android") + .header("X-Device-Id", deviceId) + .build() + + val response = chain.proceed(request) + + // One retry with a force-refreshed token covers expiry races. + if (response.code == 401 && token != null) { + val refreshed = runBlocking { tokenProvider.idToken(forceRefresh = true) } + if (refreshed != null && refreshed != token) { + response.close() + return chain.proceed( + original.newBuilder() + .header("Authorization", "Bearer $refreshed") + .header("X-Client", "worktrack-android") + .header("X-Device-Id", deviceId) + .build(), + ) + } + } + return response + } +} diff --git a/core/network/src/main/kotlin/app/worktrack/core/network/serializer/JavaTimeSerializers.kt b/core/network/src/main/kotlin/app/worktrack/core/network/serializer/JavaTimeSerializers.kt new file mode 100644 index 0000000..d601ed4 --- /dev/null +++ b/core/network/src/main/kotlin/app/worktrack/core/network/serializer/JavaTimeSerializers.kt @@ -0,0 +1,34 @@ +package app.worktrack.core.network.serializer + +import java.time.Instant +import java.time.LocalDate +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** Wire format: ISO-8601 UTC instant, e.g. 2026-07-17T08:30:00Z. */ +object InstantSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("java.time.Instant", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Instant) = + encoder.encodeString(value.toString()) + + override fun deserialize(decoder: Decoder): Instant = + Instant.parse(decoder.decodeString()) +} + +/** Wire format: ISO-8601 calendar date, e.g. 2026-07-17. */ +object LocalDateSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("java.time.LocalDate", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: LocalDate) = + encoder.encodeString(value.toString()) + + override fun deserialize(decoder: Decoder): LocalDate = + LocalDate.parse(decoder.decodeString()) +} diff --git a/core/sync/build.gradle.kts b/core/sync/build.gradle.kts new file mode 100644 index 0000000..a2bf375 --- /dev/null +++ b/core/sync/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + alias(libs.plugins.worktrack.android.library) + alias(libs.plugins.worktrack.android.hilt) +} + +android { + namespace = "app.worktrack.core.sync" +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.data) + + implementation(libs.androidx.work.runtime) + implementation(libs.hilt.ext.work) + ksp(libs.hilt.ext.compiler) + implementation(libs.kotlinx.coroutines.android) +} diff --git a/core/sync/src/main/kotlin/app/worktrack/core/sync/SyncWorker.kt b/core/sync/src/main/kotlin/app/worktrack/core/sync/SyncWorker.kt new file mode 100644 index 0000000..7731754 --- /dev/null +++ b/core/sync/src/main/kotlin/app/worktrack/core/sync/SyncWorker.kt @@ -0,0 +1,41 @@ +package app.worktrack.core.sync + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.result.isRetryable +import app.worktrack.core.domain.repository.SyncRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +/** + * Executes one sync cycle (outbox push + delta pull). WorkManager provides the + * network constraint, exponential backoff, and process-death survival; the + * engine itself is idempotent, so overlapping schedules are harmless. + */ +@HiltWorker +class SyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val syncRepository: SyncRepository, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result = when (val result = syncRepository.syncNow()) { + is AppResult.Success -> Result.success() + is AppResult.Failure -> + if (result.error.isRetryable && runAttemptCount < MAX_RETRIES) { + Result.retry() + } else { + // Terminal for this run; the periodic schedule (or the next + // user action) picks it up again. Failed ops stay visible in + // the outbox and Profile sync status. + Result.failure() + } + } + + companion object { + const val MAX_RETRIES = 5 + } +} diff --git a/core/sync/src/main/kotlin/app/worktrack/core/sync/WorkManagerSyncScheduler.kt b/core/sync/src/main/kotlin/app/worktrack/core/sync/WorkManagerSyncScheduler.kt new file mode 100644 index 0000000..ebcc6f8 --- /dev/null +++ b/core/sync/src/main/kotlin/app/worktrack/core/sync/WorkManagerSyncScheduler.kt @@ -0,0 +1,64 @@ +package app.worktrack.core.sync + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import app.worktrack.core.domain.repository.SyncScheduler +import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.Duration +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WorkManagerSyncScheduler @Inject constructor( + @ApplicationContext private val context: Context, +) : SyncScheduler { + + private val workManager: WorkManager get() = WorkManager.getInstance(context) + + private val networkConstraint = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + override fun schedulePeriodicSync() { + val request = PeriodicWorkRequestBuilder(PERIODIC_INTERVAL) + .setConstraints(networkConstraint) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_INITIAL) + .build() + // KEEP: re-registering on every app start must not reset the period. + workManager.enqueueUniquePeriodicWork( + PERIODIC_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + + override fun requestImmediateSync() { + val request = OneTimeWorkRequestBuilder() + .setConstraints(networkConstraint) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_INITIAL) + .build() + // APPEND_OR_REPLACE: a punch during an active sync queues one follow-up + // run instead of cancelling the in-flight cycle. + workManager.enqueueUniqueWork( + IMMEDIATE_WORK_NAME, + ExistingWorkPolicy.APPEND_OR_REPLACE, + request, + ) + } + + private companion object { + const val PERIODIC_WORK_NAME = "worktrack.sync.periodic" + const val IMMEDIATE_WORK_NAME = "worktrack.sync.immediate" + val PERIODIC_INTERVAL: Duration = Duration.ofMinutes(30) + val BACKOFF_INITIAL: Duration = Duration.ofSeconds(30) + } +} diff --git a/core/sync/src/main/kotlin/app/worktrack/core/sync/di/SyncModule.kt b/core/sync/src/main/kotlin/app/worktrack/core/sync/di/SyncModule.kt new file mode 100644 index 0000000..2fc56d4 --- /dev/null +++ b/core/sync/src/main/kotlin/app/worktrack/core/sync/di/SyncModule.kt @@ -0,0 +1,16 @@ +package app.worktrack.core.sync.di + +import app.worktrack.core.domain.repository.SyncScheduler +import app.worktrack.core.sync.WorkManagerSyncScheduler +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface SyncModule { + + @Binds + fun bindSyncScheduler(impl: WorkManagerSyncScheduler): SyncScheduler +} diff --git "a/delivery/customer/01-\331\206\330\265\330\250-\330\247\331\276\331\204\333\214\332\251\333\214\330\264\331\206-fa.md" "b/delivery/customer/01-\331\206\330\265\330\250-\330\247\331\276\331\204\333\214\332\251\333\214\330\264\331\206-fa.md" new file mode 100644 index 0000000..51de4d2 --- /dev/null +++ "b/delivery/customer/01-\331\206\330\265\330\250-\330\247\331\276\331\204\333\214\332\251\333\214\330\264\331\206-fa.md" @@ -0,0 +1,391 @@ +# نصب اپلیکیشن موبایل WorkTrack روی تیلفون کارمندان + +این رهنما برای مسئول IT یا مدیر اداری شرکت نوشته شده است. هدف آن یک کار مشخص +است: رساندن اپلیکیشن کارمند WorkTrack روی تیلفون‌های اندرویدی کارمندان، به +صورتی که مطمئن باشید فایل اصلی است و بعداً هم قابل آپدیت باشد. + +اپلیکیشن WorkTrack در Google Play موجود نیست. فایل نصب (فایل APK) را لینومیک +مستقیم به شما می‌دهد و شما آن را روی تیلفون‌ها نصب می‌کنید. به این کار +«نصب دستی» یا sideload می‌گویند و در ادامه قدم‌به‌قدم توضیح داده شده است. + +پورتال مدیر (بخش تحت وب) نصب نمی‌خواهد؛ مدیران فقط با مرورگر به +`https://worktrack-prod.web.app` وارد می‌شوند. این سند فقط دربارهٔ اپلیکیشن +موبایل کارمند است. + +--- + +## ۱. پیش از شروع چه چیزهایی لازم است + +- تیلفون اندرویدی با اندروید ۸.۰ یا بالاتر (به بخش ۳ ببینید). +- تیلفون باید سرویس‌های Google Play داشته باشد. حاضری GPS بدون آن کار نمی‌کند + (بعضی تیلفون‌های هواوی از سال ۲۰۲۰ به بعد این سرویس را ندارند). اسکن QR کیوسک + روی این تیلفون‌ها کار می‌کند. +- فایل APK که لینومیک به شما داده است. +- برای هر کارمند یک حساب که مدیر شما از قبل در پورتال ساخته باشد. حساب در + پورتال از مسیر **کارمندان ← افزودن کارمند** ساخته می‌شود؛ پس از ساخت، پورتال + یک کادر با عنوان **«حساب کارمند ساخته شد»** نشان می‌دهد که در آن **ایمیل** و + **رمز موقت** کارمند نوشته است. همین دو معلومات برای ورود به اپلیکیشن لازم + است. (نام این خانه در پورتال «رمز موقت» است، اما این رمز خودش منقضی نمی‌شود — + به بخش ۶ ببینید.) +- اتصال انترنت روی تیلفون، حداقل برای اولین ورود. + +بدون حساب، اپلیکیشن نصب می‌شود اما کارمند نمی‌تواند وارد شود. در اپلیکیشن هیچ +گزینهٔ «ثبت‌نام» یا «رمز را فراموش کرده‌ام» وجود ندارد — این کار عمداً فقط از +دست مدیر در پورتال انجام می‌شود. + +--- + +## ۲. کدام فایل برای کدام تیلفون + +فایل‌ها را از صفحهٔ رسمی دانلود بگیرید: + +**https://worktrack-prod.web.app/app/** + +سه فایل آنجاست. هر تیلفون فقط **یکی** از این‌ها را لازم دارد: + +| فایل | برای کدام تیلفون | حجم تقریبی | +| --- | --- | --- | +| `worktrack-1.0.1-arm64.apk` | تیلفون‌های معمولی چند سال اخیر (پروسسور ۶۴ بیتی) | ۳۰ MB | +| `worktrack-1.0.1-arm32.apk` | تیلفون‌های قدیمی‌تر و ارزان‌قیمت با پروسسور ۳۲ بیتی | ۲۴ MB | +| `worktrack-1.0.1-universal.apk` | هر تیلفونی — کار می‌کند اما بسیار سنگین است | ۸۰ MB | + +> بخش وسط نام فایل شمارهٔ نسخه است و با هر نسخهٔ جدید عوض می‌شود. همیشه آنچه را +> که در صفحهٔ دانلود بالا هست بگیرید. + +**ساده‌ترین راه انتخاب:** + +۱. اول `worktrack-1.0.1-arm64.apk` را امتحان کنید. تقریباً تمام تیلفون‌هایی که + در چند سال اخیر فروخته شده‌اند از همین نوع‌اند. + +۲. اگر هنگام نصب پیام **«برنامه نصب نشد»** (App not installed) آمد، + `worktrack-1.0.1-arm32.apk` را روی همان تیلفون امتحان کنید. اندروید خودش + فایل نامناسب را رد می‌کند، پس این آزمایش هیچ خطری ندارد و چیزی خراب نمی‌شود. + +۳. اگر نمی‌خواهید برای هر تیلفون فکر کنید، `worktrack-1.0.1-universal.apk` را + بدهید. این فایل روی همه کار می‌کند، ولی نزدیک سه برابر بزرگ‌تر است و دانلود + آن با انترنت ضعیف وقت و دیتای زیاد می‌گیرد. برای همین توصیه نمی‌شود که به + صورت پیش‌فرض همین را به همه بدهید. + +اگر می‌خواهید مطمئن شوید تیلفون ۳۲ بیتی است یا ۶۴ بیتی و کیبل USB و کمپیوتر با +ابزار `adb` در دسترس دارید: + +``` +adb shell getprop ro.product.cpu.abi +``` + +نتیجهٔ `arm64-v8a` یعنی فایل arm64 و نتیجهٔ `armeabi-v7a` یعنی فایل ۳۲ بیتی. + +به هر کارمند فقط یک فایل بدهید، نه هر چهار فایل. + +--- + +## ۳. حداقل نسخهٔ اندروید: ۸.۰ + +اپلیکیشن روی **اندروید ۸.۰ (Oreo) یا بالاتر** کار می‌کند. + +روی تیلفون‌های اندروید ۷ و پایین‌تر اپلیکیشن اصلاً نصب نمی‌شود. این محدودیت +تنظیمات نیست که بشود آن را دور زد؛ نسخهٔ برنامه برای آن سیستم‌ها ساخته نشده و +هیچ نسخهٔ دیگری هم برای آن‌ها موجود نیست. کارمندی که تیلفونش اندروید ۷ یا +پایین‌تر است اصلاً نمی‌تواند از WorkTrack استفاده کند؛ او حتماً به تیلفونی با +اندروید ۸.۰ یا بالاتر ضرورت دارد. + +هیچ راهی برای ثبت حاضری چنین کارمندی از پورتال یا از کیوسک وجود ندارد. کیوسک +تنها یک QR روی صفحه نشان می‌دهد که **خود کارمند** باید آن را از داخل اپلیکیشن +WorkTrack روی تیلفون خودش اسکن کند؛ کیوسک به تنهایی حاضری کسی را ثبت نمی‌کند. + +نسخهٔ اندروید تیلفون را از **تنظیمات ← درباره تیلفون** می‌توانید ببینید. + +--- + +## ۴. تأیید اصالت فایل پیش از نصب + +هر فایل APK اصلی، یک «اثر انگشت» ریاضی به نام SHA-256 دارد. اگر فایل حتی یک +بایت تغییر کند این عدد کاملاً فرق می‌کند. پیش از پخش فایل بین کارمندان، **یک +بار** روی کمپیوتر خود این عدد را بسنجید. + +### ۴.۱ سنجش SHA-256 + +در ویندوز، در Command Prompt، در همان پوشه‌ای که فایل است: + +``` +certutil -hashfile worktrack-1.0.1-arm64.apk SHA256 +``` + +در مک یا لینوکس: + +``` +shasum -a 256 worktrack-1.0.1-arm64.apk +``` + +عددی که به شما نشان داده می‌شود باید دقیقاً با عدد همان فایل در فهرست رسمی +یکی باشد. فهرست رسمی همیشه در کنار خودِ فایل‌ها منتشر می‌شود: + +**https://worktrack-prod.web.app/app/SHA256SUMS.txt** + +آن را در مرورگر باز کنید و عدد فایلی را که دانلود کرده‌اید مقایسه کنید. (حروف +بزرگ و کوچک اهمیت ندارد؛ ویندوز فاصله میان ارقام می‌گذارد که آن هم مهم نیست.) + +> این فهرست با هر نسخهٔ جدید تازه می‌شود، برای همین در این راهنما عدد ثابتی +> نوشته نشده است — عددهای یک نسخهٔ قدیمی با فایل نسخهٔ جدید مطابقت نمی‌کنند و +> شما را بی‌جهت نگران می‌کنند. همیشه فهرست بالا را مبنا بگیرید. + +اگر عدد مطابقت نکرد، فایل را نصب نکنید. یعنی دانلود ناقص بوده یا فایل از منبع +دیگری آمده است. یک نسخهٔ تازه از لینومیک بگیرید. + +### ۴.۲ سنجش امضای دیجیتال (اختیاری ولی مطمئن‌تر) + +فایل با کلید امضای لینومیک امضا شده است. اگر روی کمپیوتر شما Android SDK +(بخش build-tools) نصب است، می‌توانید امضا را هم ببینید: + +``` +apksigner verify --print-certs -v worktrack-1.0.1-arm64.apk +``` + +خروجی باید شامل این سطرها باشد: + +``` +Verified using v2 scheme (APK Signature Scheme v2): true +Verified using v3 scheme (APK Signature Scheme v3): true +Signer #1 certificate DN: CN=Aminullah Hashemi, OU=Unknown, O=Linumic, L=Kabul, ST=Kabul, C=Af +Signer #1 certificate SHA-256 digest: e37a2ec8cdd024198dda6db7e97bb30ce03344a9bfbe47ab7db15280f4a7d983 +Signer #1 key algorithm: RSA +Signer #1 key size (bits): 4096 +``` + +در همین خروجی سطر `Verified using v1 scheme (JAR signing): false` هم دیده +می‌شود. این درست است و اشکال نیست: امضای نوع v1 فقط برای اندروید ۶ و پایین‌تر +لازم بود و این اپلیکیشن از اندروید ۸ به بعد کار می‌کند. + +اگر `apksigner` ندارید، اشکالی ندارد؛ سنجش SHA-256 در بخش ۴.۱ کافی است. علاوه +بر آن، خود اندروید هنگام هر آپدیت امضا را می‌سنجد و اجازه نمی‌دهد فایلی که با +کلید دیگری امضا شده روی نسخهٔ نصب‌شده بنشیند. + +--- + +## ۵. اجازهٔ نصب از منابع ناشناس، و خود نصب + +اندروید به صورت پیش‌فرض اجازه نمی‌دهد اپلیکیشنی خارج از Google Play نصب شود. +در اندروید ۸ و بالاتر این اجازه **برای هر برنامه جداگانه** داده می‌شود — یعنی +به برنامه‌ای که فایل APK را باز می‌کند (معمولاً «فایل‌ها»/Files یا مرورگر +Chrome)، نه به کل تیلفون. + +### راه اول (ساده‌ترین) — اجازه دادن در جریان نصب + +۱. فایل APK را روی تیلفون کارمند بگذارید: با کیبل USB، بلوتوث، کارت حافظه، یا + لینک دانلود. + +۲. برنامهٔ **فایل‌ها** (Files / My Files) را باز کنید، به پوشهٔ **Download** + بروید و روی فایل APK ضربه بزنید. + +۳. اندروید پیام می‌دهد که این منبع اجازهٔ نصب ندارد و دکمهٔ **تنظیمات** + (Settings) را نشان می‌دهد. روی آن ضربه بزنید. + +۴. سویچ **«اجازهٔ نصب از این منبع»** (Allow from this source) را روشن کنید. + +۵. با دکمهٔ بازگشت برگردید. حالا صفحهٔ نصب می‌آید. **نصب** (Install) را بزنید. + +۶. بعد از پایان، **باز کردن** (Open) را بزنید یا آیکون **WorkTrack** را از صفحهٔ + برنامه‌ها اجرا کنید. + +### راه دوم — از قبل در تنظیمات + +**تنظیمات ← برنامه‌ها ← دسترسی‌های خاص ← نصب برنامه‌های ناشناس** و سپس برنامهٔ +«فایل‌ها» را انتخاب و سویچ را روشن کنید. + +نام دقیق این منوها در برندهای مختلف (سامسونگ، شیائومی، هواوی، اینفینکس…) کمی +فرق می‌کند، اما ترتیب همیشه همین است: تنظیمات ← برنامه‌ها ← دسترسی خاص ← نصب +برنامه‌های ناشناس. + +توصیه: بعد از پایان نصب، همین سویچ را دوباره خاموش کنید. برای آپدیت‌های بعدی +یک بار دیگر روشنش می‌کنید. + +--- + +## ۶. اولین اجرا و ورود کارمند + +بعد از باز کردن اپلیکیشن، کارمند این صفحه را می‌بیند: + +- عنوان **WorkTrack** و زیر آن جملهٔ «مدیریت هوشمند نیروی کار برای افغانستان» +- خانهٔ **ایمیل کاری** +- خانهٔ **رمز عبور** (با آیکون چشم برای نمایش رمز) +- دکمهٔ **ورود** + +کارمند همان ایمیل و رمز موقتی را وارد می‌کند که مدیر از پورتال به او داده است. +اگر رمز کمتر از ۸ حرف تایپ شود، پیام «رمز عبور باید حداقل ۸ حرف باشد» ظاهر +می‌شود؛ رمز موقتی که پورتال می‌سازد همیشه از این حد بیشتر است. + +این رمز خودش منقضی نمی‌شود و کارمند نمی‌تواند آن را در اپلیکیشن تغییر دهد؛ در +بخش پروفایل هیچ گزینهٔ تغییر رمز وجود ندارد. تنها مدیر می‌تواند از پورتال رمز نو +تعیین کند. پس رمز را فقط به خود کارمند بدهید و آن را جای دیگری نگه ندارید. + +اولین ورود **حتماً انترنت می‌خواهد**. بعد از آن اپلیکیشن آفلاین هم کار می‌کند و +حاضری‌های ثبت‌شده در نوبت می‌مانند تا انترنت وصل شود. + +پس از ورود، پایین صفحه این بخش‌ها دیده می‌شود: **خانه**، **حاضری**، **رخصتی** +و **پروفایل**. (بخش رخصتی فقط وقتی نمایش داده می‌شود که شرکت شما آن ماژول را +فعال کرده باشد.) + +### اپلیکیشن چه اجازه‌هایی می‌خواهد + +اپلیکیشن هنگام اولین اجرا هیچ اجازه‌ای نمی‌گیرد. اجازه‌ها فقط در لحظه‌ای خواسته +می‌شوند که واقعاً لازم‌اند، و فقط دو مورد است: + +- **موقعیت مکانی (GPS)** — برای سنجش «ساحهٔ کاری» (geofence) لازم است؛ اپلیکیشن + باید ببیند کارمند هنگام ثبت ورود داخل محدودهٔ دفتر است یا نه. + + کادر اجازه همان لحظه‌ای که کارمند اولین بار بخش **حاضری** را باز می‌کند + می‌آید. کارمند می‌تواند **«دقیق»** (Precise) یا **«تقریبی»** (Approximate) را + انتخاب کند — هر دو برای حاضری کافی است، چون شعاع ساحهٔ کاری از خطای حالت + تقریبی بسیار بزرگ‌تر است. **«هنگام استفاده از برنامه»** را انتخاب کنید. + + اگر کارمند اشتباهی **رد** کرد، در همان صفحهٔ حاضری دکمهٔ تلاش دوباره هست؛ + و اگر اندروید دیگر کادر را نشان ندهد، از تنظیمات بدهید: + **تنظیمات ← برنامه‌ها ← WorkTrack ← اجازه‌ها ← موقعیت مکانی ← «هنگام استفاده + از برنامه»**. + + > اگر نسخهٔ **۱.۰.۰** روی تیلفونی نصب است، روی اندروید ۱۲ و بالاتر هیچ کادر + > اجازه‌ای نمی‌آمد و باید دستی از تنظیمات داده می‌شد. این در **۱.۰.۱** حل شده + > است؛ نسخهٔ تازه را از صفحهٔ دانلود بگیرید و روی همان نصب کنید — اطلاعات پاک + > نمی‌شود. + +- **کمره** — فقط وقتی کارمند دکمهٔ **«اسکن QR کیوسک»** را بزند، یا اگر شرکت شما + ویژگی تشخیص چهره را فعال کرده باشد. تشخیص چهره به صورت پیش‌فرض خاموش است. + +اپلیکیشن اجازهٔ مخاطبین، پیام، فایل‌ها، میکروفون یا اطلاع‌رسانی (نوتیفیکیشن) +نمی‌خواهد و هیچ اعلانی هم نمی‌فرستد. + +اگر شرکت شما هیچ ساحهٔ کاری تعریف نکرده باشد، در صفحهٔ حاضری این پیام دیده +می‌شود: «محدودهٔ کاری تعریف نشده — از هر جا می‌توانید حاضری بزنید». + +### دو تنظیم مفید در بخش پروفایل + +- **زبان**: زبان پیش‌فرض دری است. کارمند می‌تواند از **پروفایل ← زبان** بین + **دری**، **پښتو** و **English** انتخاب کند. +- **ورود با اثر انگشت**: از **پروفایل ← امنیت** قابل روشن کردن است. اگر تیلفون + اثر انگشت ثبت‌شده نداشته باشد، این گزینه خاکستری است و پیام «این دستگاه اثر + انگشت ثبت‌شده ندارد» را نشان می‌دهد. + +در همان بخش پروفایل، زیر عنوان **درباره**، نسخهٔ نصب‌شده نوشته شده است +(«نسخهٔ 1.0.1») و دکمهٔ **تماس با پشتیبانی** شمارهٔ لینومیک را در شماره‌گیر +تیلفون باز می‌کند. + +--- + +## ۷. آپدیت نسخه‌های بعدی + +اپلیکیشنی که به این روش نصب می‌شود **خودش آپدیت نمی‌شود**. هیچ خبری از آپدیت +هم روی تیلفون نمی‌آید. هر بار که لینومیک نسخهٔ نو می‌دهد، همان کاری را تکرار +می‌کنید که بار اول کردید: + +۱. فایل نو را از لینومیک بگیرید و SHA-256 آن را بسنجید (بخش ۴). +۲. فایل را روی تیلفون بگذارید و روی آن ضربه بزنید. +۳. این بار اندروید به جای «نصب» دکمهٔ **آپدیت** (Update) را نشان می‌دهد. بزنید. + +**اپلیکیشن قبلی را پاک نکنید.** نصب نسخهٔ نو روی نسخهٔ قدیمی می‌نشیند و تمام +معلومات کارمند (نشست ورود، تنظیم زبان، حاضری‌هایی که هنوز همگام نشده‌اند) سر +جای خود باقی می‌ماند. + +**هشدار مهم دربارهٔ پاک کردن (Uninstall):** پشتیبان‌گیری خودکار اندروید برای +این اپلیکیشن خاموش است. اگر اپلیکیشن پاک شود، هر حاضری‌ای که هنوز به سرور +نرفته باشد از بین می‌رود. اگر مجبورید اپلیکیشن را پاک کنید (مثلاً چون فایل +اشتباهی نصب شده)، اول در **پروفایل ← همگام‌سازی** ببینید که پیام **«همه چیز +به‌روز است»** نوشته باشد. اگر پیام «… تغییر در انتظار همگام‌سازی» بود، دکمهٔ +**همگام‌سازی فوری** را بزنید و صبر کنید. + +**هشدار دوم — مصرف شدن یک جای دستگاه:** پاک کردن اپلیکیشن یا پاک کردن معلومات +آن (Clear data) یک جای دستگاه از لایسنس شما را مصرف می‌کند، چون بعد از نصب +دوباره تیلفون به عنوان یک دستگاه نو شناخته می‌شود و ثبت قدیمی هم تا وقتی که +کسی آن را لغو نکند اشغال باقی می‌ماند. بعد از نصب دوباره، مدیر باید در پورتال از +**دستگاه‌ها و لایسنس** ثبت قدیمی همان کارمند را **لغو** کند تا جای آن آزاد شود. +اگر این کار نشود و چند تیلفون از نو نصب شوند، سقف لایسنس پر می‌شود و کارمندان +دیگر نمی‌توانند وارد شوند؛ بالا بردن سقف فقط از دست لینومیک است. + +نکتهٔ فنی: نسخهٔ نو باید با همان کلید لینومیک امضا شده باشد، در غیر آن اندروید +اجازهٔ آپدیت نمی‌دهد و باید اپلیکیشن پاک و از نو نصب شود. تمام نسخه‌هایی که ما +می‌دهیم با همین یک کلید امضا می‌شوند. + +--- + +## ۸. سه مشکل رایج و راه حل آن‌ها + +### مشکل ۱ — پیام «برنامه نصب نشد» (App not installed) + +معمولاً یکی از این سه دلیل را دارد: + +- **فایل با پروسسور تیلفون جور نیست.** فایل `worktrack-1.0.1-arm32.apk` یا + `worktrack-1.0.1-universal.apk` را امتحان کنید. +- **اندروید تیلفون قدیمی‌تر از ۸.۰ است.** در این حالت راهی نیست؛ به بخش ۳ + ببینید. +- **نسخهٔ قدیمی‌تر یا نسخهٔ دیموی WorkTrack روی تیلفون است.** نسخهٔ دیمو + اپلیکیشن جداگانه‌ای است و با نسخهٔ اصلی تداخل نمی‌کند، ولی اگر قبلاً یک فایل + APK از منبع دیگری نصب شده باشد باید اول آن را پاک کنید (پیش از آن **هر دو + هشدار بخش ۷** را بخوانید: حاضری‌های همگام‌نشده از بین می‌رود، و پاک کردن یک + جای دستگاه از لایسنس را مصرف می‌کند که مدیر باید ثبت قدیمی را از پورتال لغو + کند). + +### مشکل ۲ — Play Protect جلو نصب را می‌گیرد + +گوگل هر اپلیکیشنی را که از بیرون Play نصب شود مشکوک می‌شمارد و ممکن است پیامی +مثل «برنامهٔ ناشناس مسدود شد» یا پیشنهاد «فرستادن برنامه برای بررسی» نشان دهد. + +راه حل: روی **جزئیات بیشتر** (More details) ضربه بزنید و بعد **بازهم نصب کن** +(Install anyway) را انتخاب کنید. اگر SHA-256 فایل را مطابق بخش ۴ سنجیده‌اید، +فایل همان فایلی است که لینومیک ساخته و امضا کرده است. + +### مشکل ۳ — کارمند نمی‌تواند حاضری بزند و پیام موقعیت می‌آید + +اگر در صفحهٔ حاضری این پیام دیده شود: + +> بدون اجازهٔ موقعیت، حاضری GPS ممکن نیست. از QR کیوسک استفاده کنید. + +یعنی اجازهٔ موقعیت داده نشده است — معمولاً چون کارمند کادر اجازه را رد کرده +است. راه حل: + +**تنظیمات ← برنامه‌ها ← WorkTrack ← اجازه‌ها ← موقعیت مکانی** و انتخاب +**«هنگام استفاده از برنامه»**، و روشن کردن سویچ **«استفاده از موقعیت دقیق»**. + +اگر پیام دیگری آمد: + +> موقعیت GPS دریافت نشد. به جای بازتر بروید و دوباره تلاش کنید. + +یعنی اجازه درست است اما GPS هنوز موقعیت را نگرفته. داخل ساختمان‌های بتنی این +عادی است؛ نزدیک کلکین یا بیرون امتحان کنید و دکمهٔ **تلاش دوباره** را بزنید. + +اگر این پیام روی یک تیلفون **همیشه** می‌آید و بیرون از ساختمان هم حل نمی‌شود، +ببینید که آن تیلفون سرویس‌های Google Play دارد یا نه (بخش ۱). بدون آن، حاضری GPS +روی آن تیلفون هیچ‌وقت کار نمی‌کند و کارمند باید از **«اسکن QR کیوسک»** استفاده +کند. + +### پیام‌های دیگری که ممکن است ببینید + +- **«ایمیل یا رمز عبور نادرست است»** — ایمیل را از روی پورتال دوباره بسنجید. اگر + رمز گم شده، مدیر در بخش **کارمندان** روی **ویرایش** همان کارمند می‌زند، در + بخش **حساب ورود** دکمهٔ **تعیین رمز** را می‌زند (اگر خانهٔ رمز را خالی + بگذارد، یک رمز تصادفی ساخته می‌شود) و رمز نو را که در کادر **«حساب کارمند + ساخته شد»** نشان داده می‌شود به کارمند می‌دهد. در خود اپلیکیشن راهی برای + بازیابی رمز نیست. +- **«آفلاین هستید. تغییرات ذخیره شد و به صورت خودکار همگام می‌شود.»** — این خطا + نیست. کار کارمند ثبت شده و با وصل شدن انترنت خودش بالا می‌رود. +- **پیام انگلیسی دربارهٔ device seats** (مثلاً + «All 10 device seats on this licence are in use») — سقف دستگاه‌های لایسنس شما + پر شده است. مدیر می‌تواند در پورتال از **دستگاه‌ها و لایسنس** یک دستگاه + بی‌استفاده را **لغو** کند تا جا باز شود. اغلب همین ثبت‌های قدیمیِ تیلفون‌هایی + است که اپلیکیشن روی آن‌ها پاک و دوباره نصب شده — هر نصب دوباره یک جای نو + می‌گیرد (هشدار دوم بخش ۷). برای افزایش سقف باید با لینومیک تماس + بگیرید؛ پلان، تعداد دستگاه و تاریخ انقضای لایسنس را فقط لینومیک تغییر + می‌دهد و در پورتال فقط برای دیدن است. + +--- + +## ۹. تماس با پشتیبانی + +اگر مشکلی حل نشد، با ما تماس بگیرید: + +- تلفن: ‎+93 793 817 977 +- ایمیل: contact@linumic.com +- وب‌سایت: linumic.com +- آدرس: کابل، افغانستان + +هنگام تماس، **شناسهٔ شرکت** خود را آماده داشته باشید. مدیر می‌تواند آن را در +پورتال از **تنظیمات ← پشتیبانی** ببیند و کپی کند. لایسنس شما به همین شناسه صادر +شده است. diff --git "a/delivery/customer/02-\330\261\330\247\331\207\331\206\331\205\330\247\333\214-\331\205\330\257\333\214\330\261-fa.md" "b/delivery/customer/02-\330\261\330\247\331\207\331\206\331\205\330\247\333\214-\331\205\330\257\333\214\330\261-fa.md" new file mode 100644 index 0000000..2f35e09 --- /dev/null +++ "b/delivery/customer/02-\330\261\330\247\331\207\331\206\331\205\330\247\333\214-\331\205\330\257\333\214\330\261-fa.md" @@ -0,0 +1,579 @@ +# راهنمای مدیر ورک‌ترک — از حساب خالی تا اولین معاش درست + +این راهنما برای مدیر شرکت است؛ یعنی کسی که پورتال ورک‌ترک را باز می‌کند، کارمندان را ثبت می‌کند و معاش را اجرا می‌کند. دانش تخنیکی لازم نیست، اما ترتیب مراحل مهم است: هر مرحله بر مرحلهٔ پیش از خود تکیه دارد. + +دو موضوع در این راهنما مستقیم با پول مردم سروکار دارد. آن دو در بخش «دو نکته‌ای که پول را کم و زیاد می‌کند» آمده‌اند. اگر وقت کم دارید، حداقل همان بخش را بخوانید. + +--- + +## ۱. پیش از شروع + +- **آدرس پورتال:** https://worktrack-prod.web.app +- **نسخهٔ نمایشی (برای تمرین، بدون تأثیر بر داده‌های واقعی):** https://demo.linumic.com +- **مرورگر:** کروم، ایج یا فایرفاکس روی کمپیوتر. پورتال روی موبایل هم باز می‌شود، اما کار با جدول‌ها روی صفحهٔ بزرگ آسان‌تر است. +- **اپ کارمندان:** فقط اندروید، اندروید ۸ به بالا. فایل‌های نصب (APK) در بستهٔ تحویل شماست. +- **زبان:** در نوار بالای پورتال سه دکمه است: **دری**، **پښتو**، **English**. این راهنما بر اساس زبان دری نوشته شده و همهٔ نام‌های صفحه و دکمه دقیقاً همان چیزی است که در حالت دری می‌بینید. +- **حالت روشن/تاریک:** دکمهٔ **حالت تاریک** / **حالت روشن** در همان نوار بالا. + +--- + +## ۲. ورود به پورتال + +### اگر شرکت شما قبلاً ساخته شده + +۱. آدرس پورتال را باز کنید. +۲. **ایمیل کاری** و **رمز عبور** خود را بنویسید و **ورود** را بزنید. + +پیام‌های خطایی که ممکن است ببینید: + +- **«ایمیل یا رمز عبور نادرست است»** — یکی از دو مورد غلط است. +- **«این حساب دسترسی مدیریتی ندارد»** — این حساب، حساب یک کارمند عادی است. کارمندان از اپ موبایل استفاده می‌کنند، نه از پورتال. + +> **در این نسخه دکمهٔ «رمز عبور را فراموش کرده‌ام» وجود ندارد.** اگر رمز مدیر گم شد، خودتان راهی برای بازنشانی آن ندارید و باید با لینومیک تماس بگیرید (بخش ۱۲). رمز مدیر را جایی امن نگه دارید. + +### اگر شرکت شما هنوز ساخته نشده + +۱. در صفحهٔ ورود روی **«شرکت جدید؟ ثبت‌نام کنید»** کلیک کنید. +۲. این خانه‌ها را پر کنید: **نام شرکت**، **نام مدیر**، **تخلص مدیر**، **ایمیل کاری**، **رمز عبور** (حداقل ۸ حرف). +۳. **ایجاد فضای کاری** را بزنید. +۴. صفحهٔ **«ایمیل خود را تأیید کنید»** باز می‌شود و یک لینک تأیید به ایمیل شما فرستاده می‌شود. روی لینک کلیک کنید و بعد وارد شوید. اگر پیام در صندوق ورودی نبود، پوشهٔ اسپم را ببینید یا **ارسال دوبارهٔ لینک** را بزنید. + +تا وقتی ایمیل تأیید نشود، ورود ممکن نیست. + +**هنگام ثبت‌نام، سیستم این‌ها را خودکار می‌سازد:** + +- شرکت شما با واحد پول **AFN** و منطقهٔ زمانی **Asia/Kabul**؛ +- یک شعبه به نام **دفتر مرکزی** (کود HQ)؛ +- یک شیفت به نام **شیفت روز**، از ۰۸:۰۰ تا ۱۶:۰۰، با ۶۰ دقیقه استراحت؛ +- حساب کارمندی خود شما با کود **E-001**؛ +- دو نوع رخصتی: **رخصتی سالانه** (۲۰ روز در سال) و **رخصتی مریضی** (۱۰ روز در سال)؛ +- **نوروز** و **روز استقلال** برای سال جاری و سال آینده در تقویم کاری. + +--- + +## ۳. منوی کنار و کار هر بخش + +منو در کنار صفحه است و بسته به اینکه کدام ماژول‌ها روشن باشند، کم و زیاد می‌شود: + +| بخش | برای چه کاری است | +|---|---| +| **داشبورد** | نمای کلی امروز: کارمندان فعال، حاضر، غیرحاضر، در رخصتی، ناوقت، نیم روز، رخصتی‌های در انتظار، نرخ حاضری و روند ۷ روزهٔ حاضری. | +| **کارمندان** | ثبت کارمند جدید، ویرایش، معاش اساسی، ساخت حساب ورود و تعیین رمز. | +| **حاضری** | تختهٔ حاضری روزانه و هفتگی، و تایید یا رد **درخواست‌های اصلاح حاضری**. | +| **شیفت‌ها** | تعریف شیفت‌ها و **روستر روزانه** (تخصیص شیفت به کارمندان). | +| **رخصتی‌ها** | تایید یا رد درخواست‌های رخصتی. | +| **معاش** | اجرای معاش برای یک ماه شمسی و دیدن فیش‌ها. | +| **مالی** | هزینه‌ها، دفتر کل و گزارش‌های مالی. (اختیاری؛ اگر لازم ندارید خاموشش کنید.) | +| **کیوسک** | صفحهٔ تمام‌صفحهٔ QR برای ثبت حاضری با تبلت. | +| **دستگاه‌ها و لایسنس** | دیدن لایسنس و دستگاه‌های ثبت‌شده؛ لغو یا بازگردانی یک دستگاه. | +| **تنظیمات** | تنظیمات شرکت، تقویم کاری، پشتیبانی و بستن حساب. | +| **خروج** | خروج از حساب (پایین منو). | + +نام شرکت شما در نوار بالا و نام خودتان در پایین منو نشان داده می‌شود. + +--- + +## ۴. تنظیم اولیه — به همین ترتیب + +ترتیب زیر تصادفی نیست. معاش اساسی با واحد پولی ذخیره می‌شود که در تنظیمات است؛ محاسبهٔ غیبت به روزهای تعطیل هفته و تقویم کاری تکیه دارد. اگر ترتیب را به هم بزنید، مجبور می‌شوید کار را دوباره انجام دهید. + +### گام ۱ — مشخصات شرکت (اول از همه) + +**تنظیمات → مشخصات** + +- **واحد پول** — پیش‌فرض `AFN`. سه حرف، مثلاً AFN یا USD. +- **منطقهٔ زمانی** — پیش‌فرض `Asia/Kabul`. «امروز» در سراسر سیستم بر اساس همین ساعت حساب می‌شود؛ اگر شما از کشور دیگری کار می‌کنید، تختهٔ حاضری باز هم روز کاری شرکت را نشان می‌دهد و نشان **«به وقت شرکت»** در بالای صفحه ظاهر می‌شود. + +بعد از تغییر، دکمهٔ **ذخیرهٔ تغییرات** در بالای صفحه را بزنید. + +> واحد پول را **قبل از** ثبت معاش‌ها نهایی کنید. واحد پول در لحظهٔ ثبت معاش روی سند معاش کارمند نوشته می‌شود. + +### گام ۲ — سیاست‌های کاری + +**تنظیمات → سیاست‌های کاری** + +- **ساعات کاری روزانه** — پیش‌فرض ۸ ساعت. +- **روزهای تعطیل هفته** — دکمه‌های شنبه تا جمعه. پیش‌فرض فقط **جمعه** روشن است. اگر شرکت شما پنجشنبه هم رخصت است، پنجشنبه را هم روشن کنید. +- **مهلت ناوقتی (دقیقه)** — پیش‌فرض ۱۰ دقیقه. +- **محاسبهٔ اضافه‌کاری** — روشن/خاموش. + +بعد **ذخیرهٔ تغییرات** را بزنید. + +> **روزهای تعطیل هفته را همین حالا درست کنید.** هر روزی که تعطیل هفته نباشد و تعطیل رسمی هم نباشد، یک روز کاری است؛ و روز کاری بدون حاضری یعنی غیبت و کسر معاش. جزئیات در بخش ۵. + +### گام ۳ — امکانات + +**تنظیمات → امکانات** + +**این سویچ‌ها فقط تعیین می‌کنند شما در پورتال چه می‌بینید؛ کار اپ کارمند را تغییر نمی‌دهند.** فقط پنج مورد یک آیتم را از منو پنهان می‌کنند: **شیفت‌بندی**، **رخصتی**، **معاش**، **مالی و حسابداری** و **کیوسک QR**. خاموش‌کردن **اصلاح حاضری**، **اعلانات**، **محدودهٔ جغرافیایی** یا **تشخیص چهره** هیچ آیتمی را از منو کم نمی‌کند. + +پیش‌فرض‌ها: + +| ماژول | پیش‌فرض | +|---|---| +| شیفت‌بندی | روشن | +| رخصتی | روشن | +| معاش | روشن | +| اصلاح حاضری | روشن | +| اعلانات | روشن | +| محدودهٔ جغرافیایی (GPS) | روشن | +| کیوسک QR | روشن | +| **تشخیص چهره** | **خاموش** | +| مالی و حسابداری | روشن | + +نکته‌های صادقانه: + +- **تنها سویچی که سرور واقعاً می‌خواند، «تشخیص چهره» است.** بقیه نه در سرور و نه در اپ کارمند خوانده نمی‌شوند. یعنی اگر **رخصتی** یا **اصلاح حاضری** را خاموش کنید، کارمند باز هم از اپ درخواست می‌فرستد؛ فقط شما صفحهٔ دیدن و تایید آن را از دست می‌دهید و درخواست‌ها بی‌جواب می‌مانند — و رخصتیِ تاییدنشده غیبت حساب می‌شود و از معاش کسر می‌گردد. **این دو را خاموش نکنید مگر اینکه واقعاً از آن‌ها استفاده نمی‌کنید.** +- **محدودهٔ جغرافیایی (GPS):** این سویچ هیچ کاری نمی‌کند. حاضری با GPS چه این سویچ روشن باشد چه خاموش، کار می‌کند. آنچه واقعاً محل حاضری را محدود می‌کند، **محدودهٔ کاری** است و **در پورتال جایی برای رسم محدودهٔ کاری وجود ندارد**. تا وقتی لینومیک محدوده را برای شما تعریف نکند، کارمند از هر جایی می‌تواند حاضری بزند و در اپ پیام **«محدودهٔ کاری تعریف نشده — از هر جا می‌توانید حاضری بزنید»** را می‌بیند. اگر محدودهٔ کاری می‌خواهید، با لینومیک تماس بگیرید. +- **تشخیص چهره:** به صورت پیش‌فرض خاموش است. اگر آن را روشن کنید، ستون **چهره** در صفحهٔ کارمندان ظاهر می‌شود، کارمند می‌تواند چهرهٔ خود را در اپ ثبت کند، و حاضری‌ای که بدون تأیید چهره ثبت شود نشان **نیاز به بررسی** می‌گیرد. +- **اعلانات:** اپ کارمند بخش **اعلانات** دارد، اما **در این نسخه صفحه‌ای برای نوشتن اعلان در پورتال وجود ندارد**. + +### گام ۴ — شعبه‌ها + +**در این نسخه پورتال صفحهٔ مدیریت شعبه ندارد.** + +هنگام ثبت‌نام یک شعبه به نام **دفتر مرکزی** ساخته می‌شود و هر کارمندی که ثبت می‌کنید خودکار به همان شعبه وصل می‌شود؛ در فورم کارمند خانه‌ای برای انتخاب شعبه نیست. اگر شرکت شما چند شعبه دارد و می‌خواهید حاضری و مدیریت هر شعبه جدا باشد، با لینومیک تماس بگیرید تا شعبه‌ها را برای شما بسازد. تا آن وقت همه‌چیز زیر یک شعبه کار می‌کند و مشکلی در حاضری و معاش ایجاد نمی‌شود. + +### گام ۵ — تقویم کاری (تعطیلات رسمی) + +**تنظیمات → تقویم کاری** + +این بخش می‌گوید شرکت در کدام روزها بسته است. زیر عنوان نوشته شده: *«روزهایی که شرکت تعطیل است. معاش این روزها و روزهای تعطیل هفته را غیبت حساب نمی‌کند.»* + +- خانهٔ **سال** را روی سال شمسی موردنظر بگذارید. +- دکمهٔ **ساخت تعطیلات ثابت سال** فقط **نوروز** و **روز استقلال** را اضافه می‌کند. این دو در تقویم شمسی تاریخ ثابت دارند. +- بقیه را خودتان اضافه کنید: **تاریخ** را انتخاب کنید، در **مناسبت** نام آن را بنویسید (مثلاً عید فطر)، در صورت لزوم سویچ **با معاش** را تنظیم کنید و **افزودن** را بزنید. +- برای حذف یک روز، دکمهٔ **حذف** در همان سطر. + +در پایین همین بخش این یادداشت آمده است: *«نوروز و روز استقلال در تقویم شمسی ثابت‌اند و خودکار ساخته می‌شوند. عید فطر، عید قربان، عاشورا و میلادالنبی قمری‌اند و تاریخشان با رؤیت هلال اعلام می‌شود — آن‌ها را هر سال خودتان اضافه کنید.»* + +دو نکتهٔ دقیق: + +- تاریخ‌ها بر اساس **تاریخ میلادی** انتخاب می‌شوند، اما در جدول معادل شمسی هم نمایش داده می‌شود. +- برچسب **با معاش / بدون معاش** فقط برای سابقهٔ خود شماست؛ **در محاسبهٔ معاش این نسخه فرقی نمی‌کند**. هر روزی که در تقویم کاری ثبت شود، اصلاً روز کاری حساب نمی‌شود و بابت آن چیزی کسر نمی‌گردد. + +### گام ۶ — کارمندان و معاش اساسی + +**کارمندان → + افزودن کارمند** + +خانه‌های فورم: + +- **کود** — کود کارمند، مثلاً `E-002`. +- **تلیفون** +- **نام** و **نام (2)** — خانهٔ دوم برای تخلص است. +- **ایمیل** — همین ایمیل نام کاربری او در اپ موبایل می‌شود. باید یکتا باشد. +- **نوع استخدام** — تمام‌وقت، نیمه‌وقت، قراردادی، کارآموز. +- **تاریخ شمولیت** +- **معاش اساسی ماهانه (AFN)** — زیر آن نوشته شده: *«بدون معاش اساسی، این کارمند در اجرای معاش فیش نمی‌گیرد.»* این خانه را خالی نگذارید. +- **نقش** — کارمند، سرگروپ، مدیر شعبه، مدیر منابع بشری، مدیر معاش، بازرس. برای کارمند عادی همان **کارمند** درست است. +- **ساخت حساب ورود برای اپ موبایل** — تیک آن به صورت پیش‌فرض زده است. اگر تیک را بردارید، کارمند حساب ورود ندارد و نمی‌تواند حاضری بزند. +- **رمز (خالی = رمز موقت خودکار)** — اگر خالی بگذارید، سیستم خودش یک رمز می‌سازد. + +**ذخیره** را بزنید. اگر حساب ورود ساخته شده باشد، پنجرهٔ **«حساب کارمند ساخته شد»** باز می‌شود و **ایمیل** و **رمز موقت** را نشان می‌دهد، با دکمهٔ **کپی**. + +> این پنجره **فقط یک بار** رمز را نشان می‌دهد. آن را همان‌جا کپی کنید و به کارمند بدهید. اگر گم شد، جای نگرانی نیست: در **ویرایش** همان کارمند دکمهٔ **تعیین رمز** رمز تازه می‌سازد. + +**ویرایش کارمند** (دکمهٔ **ویرایش** در آخر هر سطر) امکان تغییر **وضعیت** را هم می‌دهد: فعال، در رخصتی، معلق، خارج شده. کسی که از شرکت رفته است را **خارج شده** کنید؛ کارمند غیرفعال دیگر در محاسبهٔ معاش نمی‌آید. + +> **⚠ اول معاش، بعد «خارج شده».** معاش فقط برای کارمندانی اجرا می‌شود که وضعیت‌شان **فعال** است، و برای بقیه **اصلاً فیشی نمی‌سازد** — نه فیش صفر، هیچ. اگر کسی را که مثلاً ۲۰ ماه کار کرده است وسط ماه **خارج شده** کنید و بعد معاش آن ماه را اجرا کنید، او از آن ماه چیزی نمی‌گیرد. **اول معاش ماه آخرش را اجرا (یا دوباره اجرا) کنید، فیشش را ببینید، بعد وضعیت را «خارج شده» کنید.** جزئیات در بخش ۱۰. + +برای پیدا کردن یک نفر، از خانهٔ **جستجوی نام یا کود…** بالای جدول استفاده کنید. + +### گام ۷ — شیفت‌ها و روستر (اختیاری) + +**شیفت‌ها** + +هنگام ثبت‌نام یک **شیفت روز** (۰۸:۰۰ تا ۱۶:۰۰، ۶۰ دقیقه استراحت) ساخته شده است. اگر ساعات کاری شرکت شما همین است، کاری لازم نیست. + +برای شیفت جدید **شیفت جدید** را بزنید و **نام شیفت**، **کود**، **شروع**، **پایان**، **استراحت (دقیقه)**، **مهلت ورود (دقیقه)** و **مهلت خروج (دقیقه)** را پر کنید. شیفتی که پایانش پیش از شروعش باشد، **شیفت شب** شناخته می‌شود. + +در پایین همان صفحه **روستر روزانه** است: **تخصیص شیفت** را بزنید، شیفت و کارمندان را انتخاب کنید، **از تاریخ** و در صورت نیاز **تا تاریخ (اختیاری)** را بدهید و **ثبت روستر** را بزنید. + +### گام ۸ — کیوسک (اختیاری) + +اگر می‌خواهید کارمندان با اسکن QR روی یک تبلت حاضری بزنند: + +۱. **تنظیمات → دستگاه‌های کیوسک** — در خانهٔ **نام دستگاه (مثلاً کیوسک ورودی)** یک نام بنویسید و **ساخت حساب کیوسک** را بزنید. +۲. ایمیل و رمزی که نشان داده می‌شود را در مرورگر تبلت، در همان آدرس پورتال، وارد کنید. تبلت دائم روی صفحهٔ کیوسک می‌ماند. +۳. صفحهٔ کیوسک عنوان **«برای ثبت حاضری اسکن کنید»** را نشان می‌دهد و کود هر ۳۰ ثانیه تازه می‌شود. + +کارمند در اپ **اسکن QR کیوسک** را می‌زند و کود را اسکن می‌کند. + +--- + +## ۵. دو نکته‌ای که پول را کم و زیاد می‌کند + +### نکتهٔ اول: روز کاری بدون حاضری = غیبت، و از معاش کسر می‌شود + +ورک‌ترک روزهای کاری ماه را خودش می‌سازد و بعد سراغ سوابق حاضری می‌رود: + +- روزی که در **روزهای تعطیل هفته** باشد → روز کاری نیست، کسری ندارد. +- روزی که در **تقویم کاری** ثبت شده باشد → روز کاری نیست، کسری ندارد. +- **هر روز دیگر روز کاری است.** اگر برای آن روز هیچ سابقهٔ حاضری و هیچ رخصتی تاییدشده‌ای نباشد، آن روز **غیبت** حساب می‌شود و معاش آن روز کسر می‌گردد. + +پس: + +- **اگر جمعه تنها روز رخصتی شما نیست، همین حالا روزهای تعطیل هفته را درست کنید** (گام ۲). در غیر آن هر پنجشنبه برای همه غیبت ثبت می‌شود. +- **اگر عید فطر، عید قربان، عاشورا یا میلادالنبی را در تقویم کاری ثبت نکنید، آن روزها روز کاری حساب می‌شوند** و از معاش همهٔ کارمندان کسر می‌گردد. این روزها قمری‌اند و سیستم نمی‌تواند تاریخشان را حدس بزند. **هر سال، پیش از اجرای معاش آن ماه، آن‌ها را دستی اضافه کنید.** +- روزی هم که کارمند پانچ زده اما ورود معتبری ثبت نشده (وضعیت **در انتظار**) پرداخت نمی‌شود. این‌گونه روزها را با **اصلاح حاضری** درست کنید (بخش ۷). +- **نیم روز** نصف روز کسر دارد: کسی که ثبت خروج زده و کمتر از ۴ ساعت کار کرده است، نیم روز حساب می‌شود. + +### نکتهٔ دوم: کارمند بدون معاش اساسی، اصلاً فیش نمی‌گیرد + +اگر برای کارمندی معاش اساسی ثبت نشده باشد، اجرای معاش او را **کنار می‌گذارد** — نه با صفر، بلکه اصلاً فیشی برایش ساخته نمی‌شود. + +سیستم این افراد را پنهان نمی‌کند. بعد از اجرای معاش، یک قاب هشدار با این عنوان می‌بینید: + +> **«{تعداد} کارمند در این محاسبه نیامدند»** +> «برای این افراد معاش پایه ثبت نشده است. در صفحهٔ کارمندان معاش‌شان را ثبت کنید و دوباره محاسبه کنید.» + +و نام همهٔ آن‌ها زیرش فهرست می‌شود. + +**کار درست:** به **کارمندان** بروید، برای هر کدام **ویرایش** را بزنید، **معاش اساسی ماهانه** را پر کنید، **ذخیره** کنید، و بعد در صفحهٔ **معاش** همان ماه را **دوباره اجرا کنید**. اجرای دوباره چیزی را دو بار پرداخت نمی‌کند. + +> **اجرای دوباره فقط ارقام کسانی را عوض می‌کند که در همان اجرا هم حضور دارند.** اگر کسی بین دو اجرا از فهرست بیرون رفته باشد (مثلاً وضعیتش **خارج شده** شده باشد)، **فیش قدیمی او پاک نمی‌شود**: در فهرست **مشاهده**ٔ آن دوره هنوز دیده می‌شود، در حالی که مجموع‌های بالای همان دوره دیگر او را نمی‌شمارند — یعنی فهرست و مجموع با هم نمی‌خوانند. برای پرهیز از این حالت، **معاش‌ها و وضعیت‌ها را پیش از اجرا نهایی کنید، نه بعد از آن.** + +**آن قاب هشدار را نادیده نگیرید.** یک اجرای معاش که «موفق» به نظر می‌رسد ولی نصف شرکت در آن نیست، خطرناک‌ترین حالت ممکن است. + +--- + +## ۶. دعوت کارمندان و آنچه دریافت می‌کنند + +**در این نسخه سیستم به کارمند ایمیل دعوت نمی‌فرستد.** شما این سه چیز را به او می‌دهید: + +۱. **فایل نصب اپ (APK)** — از بستهٔ تحویل. برای موبایل‌های امروزی فایل `worktrack-…-arm64.apk` و اگر مطمئن نیستید فایل `worktrack-…-universal.apk` که روی همهٔ دستگاه‌ها کار می‌کند. هر دو در **https://worktrack-prod.web.app/app/** هستند. هنگام نصب، اندروید اجازهٔ نصب از منبع ناشناس می‌خواهد؛ باید یک بار اجازه داده شود. اندروید ۸ یا بالاتر لازم است. +۲. **ایمیل** او. +۳. **رمز موقت** که پورتال نشان داد. + +کارمند در اپ **ایمیل کاری** و **رمز عبور** را می‌زند و **ورود** می‌کند. بعد از ورود این‌ها را دارد: + +- **خانه** — سلام و نام، وضعیت امروز («هنوز حاضری نزده‌اید» یا «حاضری ورود ثبت شده»)، دکمه‌های **ثبت ورود** و **ثبت خروج**، شیفت امروز، بیلانس رخصتی و اعلانات. +- **حاضری** — تاریخچهٔ ماهانه و دکمهٔ **درخواست اصلاح**. + > **به کارمندان‌تان همین حالا بگویید:** در پنجرهٔ **اصلاح حاضری** اپ اجازه می‌دهد فقط یکی از دو وقت پر شود («حداقل وقت ورود یا خروج را انتخاب کنید»)، اما **درخواستی که فقط یک وقت دارد، وضعیت آن روز را درست نمی‌کند** — روز همچنان **در انتظار** می‌ماند و تمامش از معاش کسر می‌شود، حتی بعد از تایید شما. برای اینکه غیبت آن روز پاک شود، کارمند باید **هم «ورود اصلاح‌شده» و هم «خروج اصلاح‌شده»** را پر کند، و فاصلهٔ آن دو باید **حداقل ۴ ساعت** باشد؛ کمتر از آن، روز **نیم روز** ثبت می‌شود و نصف معاش آن روز باز هم کسر می‌گردد. +- **رخصتی** — **درخواست** رخصتی، دیدن **بیلانس‌ها** و **درخواست‌های من**. +- **پروفایل** و **فیش معاش** — فیش‌های نهایی‌شده به تفکیک سال و ماه. + +اولین بار که کارمند حاضری با GPS می‌زند، اپ **اجازهٔ موقعیت** می‌خواهد. اگر اجازه ندهد، پیام **«بدون اجازهٔ موقعیت، حاضری GPS ممکن نیست. از QR کیوسک استفاده کنید.»** را می‌بیند. + +> **در فایل نصب نسخهٔ ۱٫۰٫۰ که در بستهٔ تحویل شماست، این پنجرهٔ اجازه روی اندروید ۱۲ و بالاتر ظاهر نمی‌شود** و کارمند مستقیم همان پیام «بدون اجازهٔ موقعیت…» را می‌بیند. راه حل تا آن وقت: در خود موبایل به **تنظیمات → برنامه‌ها → ورک‌ترک → مجوزها → موقعیت مکانی** بروید و **«فقط هنگام استفاده از برنامه»** را انتخاب کنید. بعد از آن حاضری GPS کار می‌کند. این ایراد در کود برطرف شده است و **در نسخهٔ بعدی که لینومیک به شما می‌دهد، پنجرهٔ اجازه خودش ظاهر می‌شود** (با دو گزینهٔ «دقیق» و «تقریبی» — هر کدام کافی است) و دیگر به این کار دستی نیاز نیست. + +اپ آفلاین هم کار می‌کند: بعد از ثبت ورود پیام **«ورود ثبت شد — به صورت خودکار همگام می‌شود»** نمایش داده می‌شود و به محض وصل شدن انترنت، اطلاعات به پورتال می‌رسد. + +> **در اپ هم دکمهٔ «رمز را فراموش کردم» وجود ندارد.** اگر کارمندی رمزش را فراموش کرد، شما در **کارمندان → ویرایش → تعیین رمز** برایش رمز تازه بسازید و به او بدهید. + +--- + +## ۷. کار روزانه + +### تختهٔ حاضری + +**حاضری** → عنوان صفحه **مانیتورینگ حاضری**. + +- با دکمه‌های **روزانه** و **هفتگی** نمای صفحه عوض می‌شود. +- با خانهٔ تاریخ می‌توانید روزهای گذشته را ببینید (تاریخ آینده ممکن نیست). +- سه شمارنده در بالا: **حاضر**، **غیرحاضر**، و در صورت وجود **نیاز به بررسی**. +- جدول: **کارمند**، **وضعیت**، **اولین ورود**، **کارکرد**. + +وضعیت‌ها: **حاضر**، **غیرحاضر**، **نیم روز**، **رخصتی**، **رخصتی عمومی**، **رخصتی هفته**، **در انتظار**. + +اگر امروز روز کاری نباشد، بالای جدول نوشته می‌شود **«امروز رخصتی هفته‌وار است»** یا **«امروز تعطیل رسمی است»** به همراه **«کسی امروز حاضری ندارد و این روز غیبت حساب نمی‌شود.»** — پس جدول خالی جای نگرانی نیست. + +نشان‌های دیگری که ممکن است ببینید: + +- **«{عدد} دقیقه ناوقت»** — دیرتر از مهلت ناوقتی آمده است. +- **«نیاز به بررسی»** — حاضری بدون تأیید چهره ثبت شده است. +- نشان قرمز با یکی از این دلایل، یعنی پانچ ثبت **نشده** است: **خارج از محدودهٔ کاری**، **ساعت دستگاه نادرست است**، **خیلی دیر ارسال شد**، **جابه‌جایی غیرممکن**، **کد کیوسک نامعتبر**. اگر دلیل موجه باشد، از کارمند بخواهید **درخواست اصلاح** بفرستد. + +### تایید اصلاح حاضری + +اگر درخواست اصلاحی در انتظار باشد، در بالای همان صفحهٔ حاضری قابی به نام **اصلاح‌های در انتظار** با تعداد آن‌ها ظاهر می‌شود. برای هر سطر: **ورود پیشنهادی**، **خروج پیشنهادی** و **دلیل** کارمند را می‌بینید و **تایید** یا **رد** را می‌زنید. برای رد کردن، سیستم می‌پرسد **«دلیل رد را بنویسید:»** و بدون دلیل رد نمی‌شود. + +اگر درخواستی در انتظار نباشد، این قاب اصلاً نشان داده نمی‌شود. + +**شما به عنوان مدیر شرکت (یا مدیر منابع بشری) همهٔ اصلاح‌های در انتظارِ کل شرکت را در این قاب می‌بینید** — چه کارمند سرگروپ داشته باشد چه نداشته باشد. + +> اما حساب‌های با نقش **سرگروپ** یا **مدیر شعبه** فقط درخواست‌هایی را می‌بینند که مستقیماً به خودشان ارجاع شده باشد، و **در فورم کارمند خانه‌ای برای تعیین سرگروپ وجود ندارد**؛ پس عملاً چیزی به آن‌ها ارجاع نمی‌شود و قاب برایشان خالی می‌ماند. تایید درخواست‌ها را با حساب مدیر شرکت انجام دهید. اگر می‌خواهید سرگروپ‌ها خودشان تایید کنند، با لینومیک تماس بگیرید (بخش ۱۲). + +**تایید اصلاح فقط وقتی غیبت آن روز را پاک می‌کند که کارمند هر دو وقت — «ورود اصلاح‌شده» و «خروج اصلاح‌شده» — را نوشته باشد.** اگر فقط یکی از دو وقت را داده باشد، تایید شما آن وقت را روی روز ثبت می‌کند اما **وضعیت روز عوض نمی‌شود**: روز **در انتظار** می‌ماند و تمام معاش آن روز کسر می‌شود. و اگر فاصلهٔ آن دو وقت **کمتر از ۴ ساعت** باشد، روز **نیم روز** می‌شود و نصف معاش آن روز باز هم کسر می‌گردد. + +پس پیش از تایید، ستون‌های **ورود پیشنهادی** و **خروج پیشنهادی** را نگاه کنید. اگر یکی از آن دو خالی است، درخواست را رد کنید و از کارمند بخواهید هر دو وقت را بنویسد و دوباره بفرستد. + +اگر معاش آن ماه را قبلاً اجرا کرده‌اید، بعد از تایید اصلاح‌ها معاش را دوباره اجرا کنید. + +### رخصتی + +**رخصتی‌ها** → عنوان صفحه **تاییدی رخصتی**. + +فهرست درخواست‌های در انتظار با **کارمند**، **تاریخ‌ها**، **روزها** و **دلیل**. **تایید** یا **رد** را بزنید؛ رد کردن دلیل می‌خواهد. + +**رخصتی تاییدشده روی روزهای همان بازه ثبت می‌شود و آن روزها به عنوان رخصتی با معاش حساب می‌شوند، نه غیبت.** پس بهتر است رخصتی‌ها را **پیش از** اجرای معاش ماه تایید کنید. + +--- + +## ۸. اجرای معاش + +**معاش** + +۱. در بالای صفحه **ماه** و **سال** شمسی را انتخاب کنید. +۲. **اجرای معاش** را بزنید. تا پایان محاسبه دکمه **در حال محاسبه…** می‌شود. +۳. پیام **«معاش برای {تعداد} کارمند محاسبه شد»** ظاهر می‌شود. +۴. اگر کسی معاش اساسی نداشته باشد، قاب هشدار **«{تعداد} کارمند در این محاسبه نیامدند»** با نام آن‌ها نشان داده می‌شود. حتماً رسیدگی کنید (بخش ۵، نکتهٔ دوم). + +### «موقت» یعنی چه + +اگر ماهی را اجرا کنید که هنوز تمام نشده است، سیستم این جمله را بالای صفحه می‌نویسد: + +> «این ماه هنوز تمام نشده. ارقام فقط روزهای سپری‌شده را در بر می‌گیرد؛ پس از پایان ماه دوباره اجرا کنید.» + +و در جدول، کنار نام آن دوره نشان **موقت** می‌آید. + +اجرای موقت هیچ اشکالی ندارد و برای دیدن تخمین در وسط ماه مفید است. فقط دو چیز را بدانید: + +- روزهای باقی‌ماندهٔ ماه نه کارکرد حساب می‌شوند و نه غیبت؛ سیستم روزهای سپری‌شده **و امروز** را می‌بیند. +- چون **امروز** هم شمرده می‌شود، هر کسی که تا لحظهٔ اجرا حاضری نزده باشد در این محاسبهٔ موقت یک روز غیبت می‌خورد. این خودش در اجرای نهاییِ بعد از پایان ماه درست می‌شود؛ نگران نباشید. +- **این ارقام نهایی نیستند.** بعد از پایان ماه — و بعد از اینکه رخصتی‌ها و اصلاح‌های حاضری آن ماه را تایید کردید — همان ماه را **دوباره اجرا کنید**. اجرای دوباره جای نتیجهٔ قبلی را می‌گیرد و نشان **موقت** برداشته می‌شود. + +> **یک استثنا:** اجرای دوباره ارقام کسانی را که هنوز در محاسبه هستند به‌روز می‌کند، اما فیشِ کسی را که از محاسبه بیرون رفته (مثلاً بعداً **خارج شده** شده) پاک نمی‌کند. پس ممکن است در فهرست **مشاهده** نامی را ببینید که دیگر در جمع‌ها حساب نشده است. اگر کسی را از محاسبه بیرون می‌برید، ترتیبِ بخش ۱۰ را رعایت کنید. + +### ترتیب پیشنهادی برای پایان ماه + +۱. تعطیلات رسمی آن ماه در **تقویم کاری** ثبت باشد. +۲. همهٔ **درخواست‌های رخصتی** آن ماه تایید یا رد شده باشد. +۳. همهٔ **اصلاح‌های حاضری** آن ماه تایید یا رد شده باشد. +۴. همهٔ کارمندان فعال **معاش اساسی** داشته باشند. +۵. حالا معاش را اجرا کنید و قاب هشدار را بخوانید. + +### جدول دوره‌ها + +هر سطر یک ماه است: **دوره**، **وضعیت**، **کارمندان**، **مجموع ناخالص**، **مجموع مالیه**، **مجموع خالص**، و دکمهٔ **مشاهده**. + +--- + +## ۹. خواندن فیش معاش + +روی **مشاهده** یک دوره کلیک کنید تا فیش‌ها را ببینید. ستون‌ها: + +| ستون | معنی | +|---|---| +| **کارمند** | نام کارمند | +| **ناخالص** | معاش اساسی به‌علاوهٔ همهٔ عواید | +| **مالیه** | مالیهٔ معاش | +| **کسرات** | مجموع همهٔ کسرات — **مالیه و کسر غیرحاضری هم داخل همین رقم است** | +| **خالص** | ناخالص منهای کسرات؛ همان مبلغی که به کارمند می‌پردازید | +| **هزینهٔ کل شرکت** | ناخالص به‌علاوهٔ هزینه‌های بر دوش کارفرما | +| **روزهای کارکرد** | تعداد روزهای کارکردشده در آن ماه | + +توجه: **مالیه** جدا نشان داده می‌شود اما بخشی از **کسرات** است؛ آن را دو بار کم نکنید. + +### چطور محاسبه می‌شود + +- **کسر غیرحاضری** = (معاش اساسی ÷ تعداد روزهای کاری آن ماه) × تعداد روزهای غیبت. یعنی ارزش یک روز بر اساس روزهای کاری همان ماه است، نه بر ۳۰. این کسر هرگز از ناخالص بیشتر نمی‌شود. +- **مالیه** بر اساس مادهٔ ۴ قانون مالیات بر عایدات افغانستان، روی معاش ماهانهٔ مشمول **پس از** کسر غیرحاضری: + +| معاش ماهانهٔ مشمول (افغانی) | مالیه | +|---|---| +| تا ۵٬۰۰۰ | معاف | +| ۵٬۰۰۱ تا ۱۲٬۵۰۰ | ۲٪ مبلغ مازاد بر ۵٬۰۰۰ | +| ۱۲٬۵۰۱ تا ۱۰۰٬۰۰۰ | ۱۵۰ + ۱۰٪ مبلغ مازاد بر ۱۲٬۵۰۰ | +| بیشتر از ۱۰۰٬۰۰۰ | ۸٬۹۰۰ + ۲۰٪ مبلغ مازاد بر ۱۰۰٬۰۰۰ | + +- **خالص** هیچ‌گاه منفی نمی‌شود. + +### یک مثال + +کارمندی با معاش اساسی **۳۰٬۰۰۰** افغانی، در ماهی که **۲۶** روز کاری دارد و او **۲** روز غیبت غیرموجه داشته است: + +- ارزش یک روز: ۳۰٬۰۰۰ ÷ ۲۶ ≈ ۱٬۱۵۳٫۸۵ +- کسر غیرحاضری: ۱٬۱۵۳٫۸۵ × ۲ = **۲٬۳۰۷٫۶۹** +- معاش مشمول مالیه: ۳۰٬۰۰۰ − ۲٬۳۰۷٫۶۹ = ۲۷٬۶۹۲٫۳۱ +- مالیه: ۱۵۰ + ۱۰٪ × (۲۷٬۶۹۲٫۳۱ − ۱۲٬۵۰۰) = **۱٬۶۶۹٫۲۳** +- کسرات: ۲٬۳۰۷٫۶۹ + ۱٬۶۶۹٫۲۳ = **۳٬۹۷۶٫۹۲** +- **خالص: ۳۰٬۰۰۰ − ۳٬۹۷۶٫۹۲ = ۲۶٬۰۲۳٫۰۸** + +اگر آن دو روز در واقع رخصتی بوده و شما رخصتی را تایید کرده بودید، یا اگر آن روزها تعطیل رسمی بوده و در تقویم کاری ثبت شده بودند، هیچ کسری وجود نداشت و خالص برابر ۳۰٬۰۰۰ منهای مالیه می‌شد. + +### آنچه کارمند می‌بیند + +کارمند در اپ در بخش **فیش معاش** همین ارقام را می‌بیند: **معاش خالص**، خط «ناخالص … − کسرات …»، بخش‌های **عواید** و **کسرات**، و **خلاصهٔ حاضری** شامل **روزهای کارکرد**، **روزهای رخصتی با معاش** و **روزهای کسر معاش**. + +> **در این نسخه دکمهٔ چاپ یا خروجی PDF فیش معاش وجود ندارد** — نه در پورتال و نه در اپ. اگر به فیش کاغذی نیاز دارید، فعلاً باید از روی همین ارقام تهیه شود. + +--- + +## ۹.۱ عواید و کسرات + +هر چیزی که غیر از **معاش اساسی**، **مالیهٔ معاش** و **کسر غیرحاضری** باید در فیش +بیاید، از قاب **عواید و کسرات** در پایین صفحهٔ **معاش** تعریف می‌شود — مثل +کمک‌هزینهٔ ترانسپورت، کمک‌هزینهٔ غذا، کسر قرضه، یا سهم کارفرما در تقاعد. + +**افزودن مورد** را بزنید و این‌ها را پر کنید: + +| خانه | توضیح | +| --- | --- | +| **نام** | همان چیزی که در فیش کارمند دیده می‌شود، مثلاً «کمک‌هزینهٔ ترانسپورت». | +| **کود** | فقط حروف بزرگ انگلیسی، عدد و `_` — مثلاً `TRANSPORT`. تکراری نمی‌شود. | +| **نوع** | **عواید** (به معاش اضافه می‌شود)، **کسرات** (کم می‌شود)، یا **هزینهٔ کارفرما** (از معاش کارمند کم نمی‌شود؛ فقط هزینهٔ شرکت را نشان می‌دهد). | +| **طرز محاسبه** | **مبلغ ثابت**، **فیصدی از معاش اساسی**، یا **فیصدی از ناخالص**. | +| **مبلغ / فیصدی** | عدد. | +| **مشمول مالیه** | فقط برای عواید. اگر خاموش باشد، آن مبلغ در محاسبهٔ مالیه نمی‌آید. | + +**دو نکته:** + +- برای **عواید**، گزینهٔ «فیصدی از ناخالص» وجود ندارد. ناخالص خودش از جمع عواید + ساخته می‌شود، پس عوایدی که از ناخالص حساب شود دور می‌زند. +- خانهٔ **شمول** تعیین می‌کند این مورد به چه کسی می‌رسد: + - **همهٔ کارمندان** — به‌صورت پیش‌فرض به همه تعلق می‌گیرد. + - **فقط کارمندان مشخص** — به هیچ‌کس تعلق نمی‌گیرد مگر در پروندهٔ خودش داده شود. + +**غیرفعال کردن** به جای حذف است: مورد از محاسبه‌های بعدی بیرون می‌رود اما در +فیش‌های قبلی سر جایش می‌ماند. + +> تغییر در این فهرست روی فیش‌هایی که قبلاً ساخته شده‌اند اثر نمی‌گذارد. برای +> اعمال، معاش آن ماه را **دوباره اجرا کنید**. + +### برای یک کارمند مشخص + +**کارمندان → ویرایش** آن کارمند را باز کنید. پایین همان پنجره قاب **عواید و +کسرات این کارمند** است و همهٔ موارد شرکت را فهرست می‌کند. برای هر ردیف: + +- **شامل شود** — سویچ. برای موردی که «همهٔ کارمندان» است روشن آمده؛ خاموش کردنش + یعنی این یک نفر آن را نگیرد. برای موردی که «فقط کارمندان مشخص» است خاموش + آمده؛ روشن کردنش یعنی همین یک نفر آن را بگیرد. +- **مبلغ ویژه** — اگر خالی بگذارید، همان مبلغ عمومی شرکت اعمال می‌شود (عدد + کم‌رنگِ داخل خانه همان مبلغ عمومی است). عدد بنویسید تا فقط برای این کارمند + مبلغ دیگری اعمال شود. + +نمونه: کمک‌هزینهٔ ترانسپورت شرکت ۳۰۰۰ افغانی است، اما احمد که از ولایت می‌آید +۴۵۰۰ می‌گیرد — در پروندهٔ احمد، در ردیف ترانسپورت، ۴۵۰۰ بنویسید. بقیه همان ۳۰۰۰ +را می‌گیرند. + +> اگر مبلغ ویژه را پاک کنید، آن کارمند دوباره به مبلغ عمومی برمی‌گردد — و اگر +> بعداً مبلغ عمومی را عوض کنید، تغییر به او هم می‌رسد. برای همین بهتر است وقتی +> استثنا لازم نیست، خانه را خالی بگذارید تا عدد تکراری ثبت نشود. + +--- + +## ۱۰. دستگاه‌ها و لایسنس + +**دستگاه‌ها و لایسنس** + +در بالای صفحه یک نشان می‌گوید **«{عدد} از {عدد} دستگاه»** — یعنی چند «سیت» از سقف لایسنس شما استفاده شده است. + +### لایسنس + +قاب **لایسنس** این‌ها را نشان می‌دهد و همه فقط خواندنی‌اند: + +- **پلان** — رایگان، استندرد یا سازمانی +- **سقف دستگاه** — تعداد سیت‌ها +- **وضعیت** — فعال، معلق یا منقضی +- **تاریخ انقضا** — یا **بدون انقضا** +- **اعمال محدودیت دستگاه** — بله یا خیر + +و زیر آن این جمله: *«لایسنس شما را لینومیک صادر می‌کند. برای افزودن دستگاه، تمدید تاریخ انقضا یا تغییر پلان با ما تماس بگیرید — مشخصات تماس در بستهٔ تحویل شماست.»* + +> **لایسنس فقط توسط لینومیک صادر و تغییر داده می‌شود.** در پورتال هیچ راهی برای تغییر پلان، سقف دستگاه، تاریخ انقضا یا اعمال محدودیت وجود ندارد — این کار عمداً برداشته شده است. برای هر تغییری تماس بگیرید و شناسهٔ شرکت خود را بدهید (بخش ۱۲). + +### «سیت» یعنی چه + +هر موبایلی که اپ کارمند روی آن اجرا می‌شود و هر تبلت کیوسک، یک **سیت** می‌گیرد. مرورگر مدیر سیت نمی‌گیرد. + +- اگر **اعمال محدودیت دستگاه** خاموش باشد (حالت پیش‌فرض شرکت‌های بدون لایسنس ثبت‌شده، با سقف ۵ دستگاه)، دستگاه‌ها بدون محدودیت کار می‌کنند. +- اگر روشن باشد، وقتی سیت‌ها پر شود، دستگاه بعدی رد می‌شود تا وقتی یکی را لغو کنید یا سیت بیشتری بگیرید. +- اگر لایسنس **معلق** یا **منقضی** باشد **و اعمال محدودیت دستگاه روشن باشد**، اپ کارمندان کار نمی‌کند و پورتال مدیر باز می‌ماند. اگر اعمال محدودیت خاموش باشد، وضعیت لایسنس هیچ اثری روی کار کردن اپ ندارد. + +### وقتی کسی از شرکت می‌رود + +> **ترتیب این کارها مهم است و اشتباه کردنِ آن معاش آخرِ کارمند را از بین می‌برد.** +> معاش فقط برای کارمندان **فعال** فیش می‌سازد. اگر کسی را روزِ ۲۰ ماه **خارج شده** +> کنید و بعد معاش آن ماه را اجرا کنید، برای بیست روزی که کار کرده **هیچ فیشی +> ساخته نمی‌شود** و چیزی نمی‌گیرد. + +۱. **اول معاش ماه آخر او را اجرا کنید** (یا اگر قبلاً اجرا شده، دوباره اجرا کنید) و + فیش او را در **مشاهده** ببینید و مطمئن شوید ساخته شده است. +۲. **بعد** در **کارمندان → ویرایش** وضعیت او را **خارج شده** کنید. +۳. اگر ترتیب را اشتباه کردید، جبران‌پذیر است: وضعیت او را دوباره **فعال** کنید، معاش + آن ماه را دوباره اجرا کنید، فیش را ببینید، بعد **خارج شده** کنید. + +اگر کسی را که در آن ماه کار کرده **خارج شده** کرده باشید، پورتال بعد از اجرای معاش +هشدار می‌دهد و نام او را می‌نویسد — پس این اشتباه پنهان نمی‌ماند. + +**گرفتن دسترسی:** خودِ **خارج شده** کردن، ورودِ کارمند به اپ را قطع نمی‌کند. برای +قطع واقعی دسترسی با لینومیک تماس بگیرید تا حساب ورود او بسته شود (بخش ۱۲). + +**آزاد کردن سیت:** اگر لایسنس شما **اعمال محدودیت دستگاه = بله** دارد، موبایل او در +جدول دستگاه‌ها ثبت شده است؛ سطرش را پیدا کنید و **لغو** را بزنید تا سیت آزاد شود. +اگر اعمال محدودیت خاموش باشد، **موبایل‌ها اصلاً در این جدول ثبت نمی‌شوند** و چیزی +برای لغو کردن وجود ندارد — در آن حالت فقط تبلت‌های کیوسک در جدول دیده می‌شوند. +دکمهٔ **بازگردانی** یک لغو اشتباه را برمی‌گرداند. + +لغو دستگاه ممکن است تا حدود یک دقیقه طول بکشد تا در سرور اثر کند. + +> نکته: در جدول دستگاه‌ها، ستون **کارمند** **شناسهٔ** کارمند را نشان می‌دهد، نه نامش. برای تشخیص، ستون **دستگاه** (نام و مودل) و **آخرین اتصال** کمک می‌کند. + +--- + +## ۱۱. بستن حساب شرکت + +در پایین صفحهٔ **تنظیمات** قابی به نام **بستن حساب شرکت** است. اگر آن را اجرا کنید، حساب شرکت و تمام داده‌های آن پس از **۳۰ روز** حذف می‌شود: تمام سوابق حاضری و رخصتی، تمام اجراهای معاش، فیش‌ها و دفترکل، و حساب ورود همهٔ کارمندان و کیوسک‌ها. + +برای تأیید باید نام شرکت را دقیقاً بنویسید. تا پیش از پایان آن ۳۰ روز، دکمهٔ **لغو و فعال‌سازی دوباره** همه‌چیز را دست‌نخورده برمی‌گرداند. بعد از آن برگشت‌پذیر نیست. + +--- + +## ۱۲. پشتیبانی و شناسهٔ شرکت + +**تنظیمات → پشتیبانی** + +- **تلفن:** +93 793 817 977 +- **ایمیل:** contact@linumic.com +- **وب‌سایت:** linumic.com +- دفتر: کابل، افغانستان + +در همان قاب، زیر عنوان **شناسهٔ شرکت شما**، یک رشتهٔ حروف و اعداد با دکمهٔ **کپی** است. + +> **هنگام هر تماس، این شناسه را بدهید.** لایسنس به همین شناسه صادر می‌شود و بدون آن نمی‌توان درخواست شما را به شرکت درست وصل کرد. + +برای این موارد حتماً با لینومیک تماس بگیرید: + +- افزودن سیت، تمدید یا تغییر پلان لایسنس؛ +- ساخت شعبهٔ جدید یا تعریف محدودهٔ کاری (GPS)؛ +- بازنشانی رمز حساب مدیر؛ +- هر خطایی که با تلاش دوباره برطرف نمی‌شود. + +--- + +## ۱۳. آنچه در این نسخه وجود ندارد + +برای اینکه وقت‌تان تلف نشود، فهرست صادقانهٔ کارهایی که از پورتال نمی‌توانید انجام دهید: + +- **ساخت یا ویرایش شعبه** و **تعریف محدودهٔ کاری GPS** — از طریق لینومیک. +- **تغییر لایسنس** (پلان، سقف دستگاه، انقضا، اعمال محدودیت) — عمداً برداشته شده؛ فقط لینومیک. +- **بازنشانی رمز خودتان** — نه در پورتال و نه در اپ دکمهٔ «رمز را فراموش کردم» وجود ندارد. +- **ایمیل دعوت برای کارمند** — رمز موقت را خودتان به او می‌دهید. +- **نوشتن اعلان** — اپ اعلانات را نشان می‌دهد، اما صفحهٔ نوشتن اعلان در پورتال نیست. +- **چاپ یا PDF فیش معاش**. +- **اپ آیفون** — اپ کارمند فقط اندروید است. +- تفاوت **با معاش / بدون معاش** در تعطیلات، در محاسبهٔ این نسخه اثری ندارد. +- **تمدید سالانهٔ رخصتی** — سهمیهٔ رخصتی (۲۰ روز سالانه و ۱۰ روز مریضی) هنگام **ثبت + کارمند** و فقط برای **سال میلادیِ همان روز** ساخته می‌شود، و در پورتال جایی برای + تغییر یا تمدید آن نیست. یعنی در اول هر سال میلادی (۱ جنوری) سهمیهٔ سال جدید وجود + ندارد و درخواست رخصتیِ کارمند رد می‌شود تا وقتی لینومیک سهمیهٔ سال نو را بسازد. + **در اوایل هر سال میلادی با ما تماس بگیرید.** + +--- + +## چک‌لیست کوتاه راه‌اندازی + +- [ ] واحد پول و منطقهٔ زمانی در **تنظیمات → مشخصات** درست است. +- [ ] **روزهای تعطیل هفته** دقیقاً مطابق شرکت من است. +- [ ] تعطیلات رسمی امسال، از جمله عیدها، در **تقویم کاری** ثبت شده است. +- [ ] همهٔ کارمندان با **کود**، **ایمیل** و **معاش اساسی ماهانه** ثبت شده‌اند. +- [ ] هر کارمند حساب ورود دارد و ایمیل و رمز موقتش به او داده شده است. +- [ ] اپ روی موبایل‌ها نصب و آزمایش شده و اولین حاضری‌ها در تختهٔ **حاضری** دیده می‌شود. +- [ ] یک بار معاش ماه جاری را اجرا کرده‌ام و قاب **«… کارمند در این محاسبه نیامدند»** خالی است. +- [ ] بعد از پایان ماه، معاش را دوباره اجرا کرده‌ام و نشان **موقت** برداشته شده است. + +--- + +ورک‌ترک محصول **لینومیک** است. +تلفن +93 793 817 977 · contact@linumic.com · linumic.com · کابل، افغانستان diff --git "a/delivery/customer/03-\330\261\330\247\331\207\331\206\331\205\330\247\333\214-\332\251\330\247\330\261\331\205\331\206\330\257-fa.md" "b/delivery/customer/03-\330\261\330\247\331\207\331\206\331\205\330\247\333\214-\332\251\330\247\330\261\331\205\331\206\330\257-fa.md" new file mode 100644 index 0000000..82c7af3 --- /dev/null +++ "b/delivery/customer/03-\330\261\330\247\331\207\331\206\331\205\330\247\333\214-\332\251\330\247\330\261\331\205\331\206\330\257-fa.md" @@ -0,0 +1,159 @@ +# راهنمای کارمند — اپلیکیشن WorkTrack + +این راهنما برای کارمندان است. با این برنامه حاضری خود را ثبت می‌کنید، حاضری و فیش معاش خود را می‌بینید و درخواست رخصتی می‌دهید. + +برنامه روی اندروید ۸ و بالاتر کار می‌کند. فایل نصبی (APK) را مدیر شرکت شما می‌دهد؛ این برنامه در پلی‌ستور نیست. + +--- + +## ۱. اولین بار که وارد می‌شوید + +۱. برنامه WorkTrack را باز کنید. +۲. در خانهٔ **ایمیل کاری** ایمیلی را که مدیر شما داده است بنویسید. +۳. در خانهٔ **رمز عبور** رمزی را که مدیر شما داده است بنویسید. با دکمهٔ چشم می‌توانید رمز را ببینید تا مطمئن شوید درست تایپ شده است. +۴. دکمهٔ **ورود** را بزنید. + +برای اولین ورود، انترنت لازم است. بعد از آن برنامه شما را به یاد دارد و هر بار رمز نمی‌خواهد. + +اگر پیام **«ایمیل یا رمز عبور نادرست است»** را دیدید، ایمیل و رمز را دوباره کنترل کنید. + +**در این برنامه دکمهٔ «رمز را فراموش کرده‌ام» وجود ندارد.** اگر رمز خود را فراموش کردید یا می‌خواهید آن را تبدیل کنید، از مدیر شرکت خود بخواهید رمز تازه بسازد. + +بعد از ورود، در پایین صفحه چهار بخش می‌بینید: **خانه**، **حاضری**، **رخصتی** و **پروفایل**. اگر شرکت شما بخش رخصتی را فعال نکرده باشد، آن بخش نشان داده نمی‌شود. + +--- + +## ۲. ثبت ورود و خروج + +۱. در صفحهٔ **خانه** دکمهٔ **ثبت ورود** را بزنید (یا از پایین صفحه **حاضری** را انتخاب کنید). +۲. صفحهٔ **ثبت حاضری** باز می‌شود. برنامه اجازهٔ موقعیت (GPS) می‌خواهد — **اجازه بدهید**، بدون آن حاضری GPS ممکن نیست. اندروید دو گزینه پیش می‌کند: **موقعیت دقیق (Precise)** و **موقعیت تقریبی (Approximate)**؛ برای حاضری، **موقعیت دقیق** را انتخاب کنید. + اگر در نسخه‌ای که روی تلفن شما نصب است هیچ صندوق اجازه ظاهر نشد و برنامه باز هم موقعیت را نگرفت، اجازه را دستی بدهید: **تنظیمات اندروید → برنامه‌ها → WorkTrack → اجازه‌ها (Permissions) → موقعیت (Location) → اجازه دادن هنگام استفاده از برنامه**. بعد به برنامه برگردید و دوباره تلاش کنید. +۳. صبر کنید تا پیام **«در حال دریافت موقعیت شما…»** تمام شود. بعد یکی از این‌ها را می‌بینید: + - **داخل [نام ساحه]** — همه چیز درست است. + - **خارج از ساحهٔ کاری (… متر فاصله)** — شما بیرون از محدودهٔ کاری هستید. + - **محدودهٔ کاری تعریف نشده — از هر جا می‌توانید حاضری بزنید**. +۴. دکمهٔ **ثبت ورود** را بزنید. پیام **«ورود ثبت شد — به صورت خودکار همگام می‌شود»** ظاهر می‌شود. + +هنگام رفتن، همین کار را تکرار کنید؛ این بار دکمه **ثبت خروج** است. + +نکته‌ها: + +- اگر شرکت شما کیوسک دارد، به جای GPS می‌توانید دکمهٔ **اسکن QR کیوسک** را بزنید و کود روی صفحهٔ کیوسک را اسکن کنید. +- اگر شرکت شما «تشخیص چهره» را فعال کرده باشد، در همین صفحه دکمه‌های **ثبت چهره** و **ورود با چهره** هم دیده می‌شود. این امکان به صورت پیش‌فرض خاموش است. +- استفاده از برنامه‌های موقعیت جعلی مجاز نیست. اگر موقعیت جعلی روشن باشد، خود برنامه حاضری را ثبت نمی‌کند و پیام **«استفاده از موقعیت جعلی برای حاضری مجاز نیست.»** را نشان می‌دهد. + +--- + +## ۳. وقتی انترنت نیست + +**حاضری خود را مثل همیشه بزنید. هیچ چیز گم نمی‌شود.** + +حاضری اول در حافظهٔ خود تلفن ذخیره می‌شود و بعد به سرور فرستاده می‌شود. اگر انترنت نباشد، در صف می‌ماند و به مجرد وصل شدن انترنت خودش فرستاده می‌شود. برنامه هر نیم ساعت هم خودش تلاش می‌کند. + +GPS برای کار کردن به انترنت ضرورت ندارد، اما در جای سربسته گرفتن موقعیت وقت بیشتر می‌گیرد. اگر پیام **«موقعیت GPS دریافت نشد. به جای بازتر بروید و دوباره تلاش کنید»** آمد، بیرون بروید و دکمهٔ **تلاش دوباره** را بزنید. + +برای دیدن وضعیت صف: **پروفایل → همگام‌سازی**. + +- **همه چیز به‌روز است** — چیزی در صف نمانده. +- **… تغییر در انتظار همگام‌سازی** — این تعداد هنوز فرستاده نشده. +- **… تغییر توسط سرور رد شد** — این را به مدیر خود اطلاع بدهید. + +با دکمهٔ **همگام‌سازی فوری** می‌توانید بدون انتظار، فرستادن را شروع کنید. + +**هشدار:** تا وقتی چیزی در انتظار همگام‌سازی است، دکمهٔ **خروج از حساب** را نزنید. خروج از حساب، معلومات ذخیره‌شده روی تلفن را پاک می‌کند و حاضری‌های فرستاده‌نشده از بین می‌رود. + +--- + +## ۴. دیدن حاضری خود + +در صفحهٔ **خانه** دکمهٔ **تاریخچه** را بزنید. صفحهٔ **تاریخچهٔ حاضری** ماه به ماه (به تاریخ شمسی) نشان داده می‌شود؛ با دکمه‌های چپ و راست ماه را تبدیل کنید. + +هر روزی که در این لست می‌آید یکی از این چهار وضعیت را دارد: **حاضر**، **نیم روز**، **رخصتی** یا **در انتظار**. در صورت لزوم ساعت کارکرد، اضافه‌کاری و دقایق ناوقتی هم نوشته می‌شود. + +روزهای جمعه، رخصتی‌های عمومی و روزهایی که هیچ حاضری در آن‌ها ثبت نشده، اصلاً در این لست نشان داده نمی‌شوند. یعنی نبودن یک روز در لست به معنای درست بودن آن نیست — روزی که فراموش کرده‌اید حاضری بزنید، در این لست هیچ سطری ندارد. + +دو وضعیت از معاش شما کم می‌کند: + +- **در انتظار** یعنی حاضری آن روز معتبر شمرده نشده (مثلاً خروج زده‌اید ولی ورود ثبت نشده) — این روز مانند غیرحاضری بدون عذر از معاش کسر می‌شود. +- **نیم روز** یعنی آن روز کمتر از چهار ساعت کار ثبت شده — نیم روز معاش کسر می‌شود. + +هر دو را همان روز با **درخواست اصلاح حاضری** (بخش ۹) پیگیری کنید. + +--- + +## ۵. فیش معاش + +**پروفایل → معاشات → فیش‌های معاش من**. + +سال را با دکمه‌های چپ و راست انتخاب کنید و بعد ماه را از قطار ماه‌ها بزنید. با زدن روی یک فیش، جزئیات آن باز می‌شود: **عواید**، **کسرات**، **خلاصهٔ حاضری** و **معاش خالص**. + +فیش معاش تنها بعد از آن دیده می‌شود که مدیر معاشات آن ماه را نهایی کند. تا آن وقت پیام **«فیش‌های معاش بعد از نهایی شدن معاشات نمایش داده می‌شود»** را می‌بینید. + +**توجه:** اگر مدیر معاشات ماه جاری را پیش از ختم ماه اجرا کند، فیش آن ماه تنها روزهای گذشتهٔ همان ماه را در بر می‌گیرد و مبلغ **معاش خالص** آن کمتر از مبلغ واقعی ماه است. مدیر در پورتال چنین اجرایی را به نام «موقتی» می‌بیند، اما در برنامهٔ تلفن هیچ نشانه‌ای برای این حالت وجود ندارد و فیش دقیقاً مانند فیش نهایی نشان داده می‌شود. پس پیش از پایان ماه، فیش را نهایی حساب نکنید. + +--- + +## ۶. درخواست رخصتی + +۱. از پایین صفحه **رخصتی** را بزنید. +۲. دکمهٔ **درخواست** را بزنید (در گوشهٔ پایین چپ صفحه). +۳. در صفحهٔ **درخواست رخصتی**: **نوع رخصتی** را انتخاب کنید، **تاریخ شروع** و **تاریخ ختم** را بزنید، و اگر لازم است **نیم روز اول** یا **نیم روز آخر** را فعال کنید. + تقویمی که برای انتخاب تاریخ باز می‌شود **میلادی** است، نه شمسی. بعد از انتخاب، همان تاریخ روی دکمه به شمسی نشان داده می‌شود — آن را کنترل کنید که همان روز مورد نظر شما باشد. +۴. در بخش **دلیل** بنویسید چرا به این رخصتی ضرورت دارید. نوشتن دلیل حتمی است. +۵. **ارسال درخواست** را بزنید. + +درخواست شما اول **در انتظار** است، بعد از فیصلهٔ مدیر **تایید شده** یا **رد شده** می‌شود. تا وقتی تایید نشده، می‌توانید با **لغو درخواست** آن را پس بگیرید. + +در بالای همین صفحه **بیلانس‌ها** را می‌بینید — یعنی چند روز رخصتی برای شما باقی مانده است. + +--- + +## ۷. زبان برنامه + +**پروفایل → زبان**. سه گزینه است: **دری**، **پښتو**، **English**. با زدن هر کدام، زبان فوراً تبدیل می‌شود. + +## ۸. قفل با اثر انگشت + +**پروفایل → امنیت → ورود با اثر انگشت**. اگر این را روشن کنید، هر بار که برنامه را از نو باز می‌کنید صفحهٔ **قفل امنیتی** می‌آید و باید اثر انگشت خود را تأیید کنید. + +اگر روی تلفن شما اثر انگشتی ثبت نشده باشد، این گزینه خاموش و غیرفعال است و می‌نویسد **«این دستگاه اثر انگشت ثبت‌شده ندارد»**. اول از تنظیمات خود اندروید اثر انگشت ثبت کنید. + +--- + +## ۹. اگر مشکلی پیش آمد + +**انترنت یا آنتن ندارم.** +حاضری خود را مثل همیشه بزنید. در تلفن ذخیره می‌شود و بعداً خودش می‌رود. فقط لازم است GPS موقعیت شما را گرفته باشد. + +**فراموش کردم حاضری بزنم.** +روزی که هیچ حاضری در آن ثبت نشود، غیرحاضری بدون عذر شمرده می‌شود و از معاش کسر می‌گردد. پس همان روز یا فردای آن اقدام کنید. دو حالت است: + +*اگر آن روز حداقل یک حاضری زده‌اید* (مثلاً ورود زده‌اید ولی خروج نزده‌اید) — آن روز در تاریخچه سطر دارد و می‌توانید خودتان اصلاح را از برنامه بفرستید: +**خانه → تاریخچه** → روی آن روز، آیکن تقویم در کنار چپ کارت را بزنید → در صندوق **اصلاح حاضری** **هر دو وقت** — **ورود اصلاح‌شده** و **خروج اصلاح‌شده** — را تنظیم کنید، **دلیل** را بنویسید (مثلاً: فراموش کردم خروج بزنم) و **ارسال درخواست** را بزنید. این اصلاح بعد از تایید مدیر اعمال می‌شود. +برنامه اجازه می‌دهد تنها یکی از دو وقت را بفرستید، اما این کار فایده ندارد: **اگر تنها یکی را بنویسید، درخواست ثبت و تایید می‌شود اما وضعیت آن روز و ساعت کارکرد شما تغییر نمی‌کند** و کسر معاش همان‌طور باقی می‌ماند. حتماً هر دو وقت را بنویسید. + +*اگر آن روز هیچ حاضری نزده‌اید* — آن روز اصلاً در **تاریخچهٔ حاضری** نشان داده نمی‌شود، پس آیکن تقویمی هم برای زدن وجود ندارد و از برنامه اصلاح کرده نمی‌توانید. به مدیر یا مسئول منابع بشری شرکت خود مراجعه کنید تا آن روز را در پورتال اصلاح کند. + +**تلفن نو گرفته‌ام.** +پیش از آنکه تلفن کهنه را بدهید، مطمئن شوید در **پروفایل → همگام‌سازی** نوشته باشد **«همه چیز به‌روز است»**. بعد فایل نصبی را از مدیر خود بگیرید، در تلفن نو نصب کنید و با همان ایمیل و رمز وارد شوید. حاضری‌های گذشتهٔ شما در سرور است و گم نمی‌شود. +اگر پیامی آمد که تعداد دستگاه‌های لایسنس پر شده است (این پیام به انگلیسی می‌آید و در آن کلمهٔ *device seats* دیده می‌شود)، از مدیر شرکت خود بخواهید تلفن کهنهٔ شما را در پورتال، در بخش **دستگاه‌ها و لایسنس**، غیرفعال کند. + +**پیام «device not activated» را می‌بینم.** +پیام کامل به انگلیسی است: *This device is not activated. Sign in again to activate it.* یعنی این تلفن در لایسنس شرکت شما فعال نیست. **این پیام را خودتان حل کرده نمی‌توانید** و پیشنهادِ خودِ پیام (دوباره وارد شدن) این مشکل را حل نمی‌کند. به مدیر شرکت خود بگویید — او در پورتال، بخش **دستگاه‌ها و لایسنس**، وضعیت دستگاه‌ها و لایسنس شرکت را کنترل می‌کند. +**هیچ‌گاه برای حل مشکل، دکمهٔ خروج از حساب را نزنید** — خروج از حساب حاضری‌های فرستاده‌نشدهٔ روی تلفن شما را پاک می‌کند. + +**می‌نویسد «شما خارج از ساحهٔ کاری مجاز هستید».** +شما بیرون از محدودهٔ تعیین‌شدهٔ شرکت هستید. به داخل ساحهٔ کاری بروید و دوباره تلاش کنید. اگر واقعاً در محل کار هستید و باز هم همین پیام می‌آید، به مدیر خود اطلاع بدهید تا محدوده را کنترل کند. + +**می‌نویسد «نشست شما پایان یافته است».** +دوباره با ایمیل و رمز خود وارد شوید. + +--- + +## ۱۰. از کی بپرسم + +برای هر چیزی که به کار و معاش شما ربط دارد — رمز عبور، اصلاح حاضری، رخصتی، فیش معاش، ثبت تلفن نو — **به مدیر یا مسئول منابع بشری شرکت خودتان مراجعه کنید**. آن‌ها در پورتال WorkTrack دسترسی دارند و می‌توانند این کارها را انجام بدهند. + +در **پروفایل → درباره** دکمه‌ای به نام **تماس با پشتیبانی** است که شمارهٔ لینومیک (سازندهٔ برنامه) را در تلفن شما باز می‌کند. آن شماره برای مدیر شرکت است، نه برای مسائل شخصی کارمندان. + +معلومات شرکت شما در سرور نگهداری می‌شود و مسئول آن شرکت خودتان است. لینومیک به عنوان سازنده و نگهدارندهٔ سیستم به آن دسترسی تخنیکی دارد، اما تنها برای پشتیبانی و به درخواست شرکت شما. diff --git a/delivery/customer/04-licence-terms-en.md b/delivery/customer/04-licence-terms-en.md new file mode 100644 index 0000000..0455a64 --- /dev/null +++ b/delivery/customer/04-licence-terms-en.md @@ -0,0 +1,685 @@ +# WorkTrack Software Licence Agreement + +**Linumic — Kabul, Afghanistan** + +--- + +## READ THIS FIRST — DRAFT, NOT LEGAL ADVICE + +**This document is a working draft.** It was written by the vendor, who is not +a lawyer, to set out in plain language what Linumic actually does and what the +WorkTrack software actually does. + +**It must be reviewed by a lawyer qualified in Afghan law before it is signed +by anyone, or used with any real customer.** It has not been checked against +the Commercial Code, the Law on Commercial Contracts, tax law, labour law, or +any Afghan data or telecommunications regulation. Nothing in it is legal +advice, and no one should rely on it as if it were. + +Both parties should take their own legal advice before signing. + +Where this draft describes what the software does, those descriptions have been +checked against the product itself and are accurate as at the date on the cover +of the handover pack. Where the product cannot yet do something a customer +would reasonably expect, this draft says so plainly rather than promising it. + +--- + +## 1. The parties + +**Linumic** ("the Vendor"), a software vendor based in Kabul, Afghanistan. +Contact: +93 793 817 977, contact@linumic.com, linumic.com. + +**[Customer legal name]** ("the Customer"), the company named on the Order Form. + +"The Agreement" means this document together with the Order Form (the signed +sheet that records the plan, the number of device seats, the licence period, the +fee, and the WorkTrack company ID issued to the Customer). If the Order Form and +this document disagree, the Order Form wins. + +## 2. What WorkTrack is + +WorkTrack is an HR, attendance and payroll system with three parts: + +- A **manager portal**, used in a web browser at the address given in the + handover pack. Sections: Dashboard, Employees, Attendance, Shifts, Leave, + Payroll, Finance, Kiosk, Devices & licence, and Settings. +- An **employee app for Android**, supplied by Linumic as a signed APK file. + It requires Android 8.0 or later. +- A **kiosk mode**, a browser screen for a shared tablet, using a kiosk login + created in the portal. + +The software is trilingual (Dari, Pashto, English), right-to-left first, uses +the Solar Hijri calendar, Afghani (AFN) as the default currency, Asia/Kabul as +the default timezone, and a Saturday-to-Thursday working week with Friday as the +weekend by default. Payroll income tax is calculated using the monthly brackets +of Article 4 of the Afghan Income Tax Law. + +## 3. Grant of licence + +Subject to payment of the fees and to the terms below, the Vendor grants the +Customer a **non-exclusive, non-transferable, revocable licence** to use +WorkTrack for the Customer's own internal business purposes, for the licence +period stated on the Order Form. + +The licence is granted: + +**a) To one named company.** The licence is issued against a single WorkTrack +company ID. That ID is shown in the portal under **Settings → Support**, next to +the "Your company ID" heading, with a Copy button. It is the identifier the +Vendor uses for every licence, renewal and support conversation. The licence +does not extend to a parent company, a subsidiary, a sister company, a joint +venture, or any other legal entity, unless that entity is named on the Order +Form and has its own company ID. + +**b) For a stated number of device seats.** The Order Form states a device +limit. **Where enforcement of the device limit is switched on for that licence**, +every Android phone running the employee app and every kiosk tablet occupies one +seat. Where enforcement is switched off — which is the position for any company +to which the Vendor has not yet issued a licence with enforcement on — the limit +is contractual only: no phone ever takes a seat, the registered device list stays +empty, and no device is ever refused. Whether enforcement is on is visible to the +Customer under **Devices & licence**. + +Manager access through a web browser does **not** consume a seat — a manager, HR +administrator or accountant can sign in to the portal from any computer. + +A device holds its seat until an administrator revokes it in the portal under +**Devices & licence** (the **Revoke** button on the device row). Revoking frees +the seat. A revoked device can be restored later with **Restore**, which takes +a seat again. When every seat is in use, the next new phone is refused; existing +devices keep working. Two limits of that refusal should be understood: + +- **The app does not name the seat limit as the reason.** The server sends an + explanatory message, but the Android app shows a general permission error + instead — the employee sees "You don't have permission to do that." Tell staff + in advance, or the call will arrive as a permissions question rather than a + licence one. +- **Kiosk logins are not refused at the seat limit.** Creating a kiosk login in + the portal always succeeds, even when every seat is already in use. The kiosk + still counts against the limit afterwards, so a company can be pushed over its + seat count this way and then find the next new phone refused. Only phones are + actually refused at the limit today. + +**c) For a stated term.** The Order Form states an expiry date, or states that +the licence is perpetual. The expiry date is judged against the calendar date in +the Customer's own timezone, so a licence does not lapse early. + +**d) On the plan stated.** Plans are Free, Standard or Enterprise. The plan, +seat count, status and expiry date are visible to the Customer, read-only, in +the portal under **Devices & licence**. + +**Only the Vendor can issue or change a licence.** There is deliberately no +control in the portal, and no API endpoint, that lets a customer change their own +plan, seat count, expiry date or enforcement setting. The portal says so: +"Your licence is issued by Linumic. To add device seats, extend the expiry date +or change your plan, contact us." Changes are made by the Vendor using +credentials for the hosting project that no customer holds. + +## 4. What is not granted + +The Customer must not, and must not permit anyone else to: + +- **Resell, sublicense, rent, lease or host** WorkTrack for a third party, or + operate it as a service for any company other than the one named on the Order + Form. +- **Reverse engineer, decompile or disassemble** the software, or attempt to + derive its source code, except to the extent that Afghan law expressly permits + this and cannot be contracted out of. +- **Copy, modify or create derivative works** of the software, its APK files, or + its documentation, beyond the ordinary use and internal backup that this + licence contemplates. +- **Share login credentials.** Each person who uses WorkTrack must have their + own account. Kiosk logins are the one exception: a kiosk login belongs to a + tablet, not to a person, and exists so that a shared check-in screen does not + need a manager's password. Kiosk credentials must still be kept confidential + and must not be used to sign in to the manager portal. +- **Remove or obscure** the Vendor's name, notices or branding from the + software. +- **Use the software to break the law**, including Afghan labour law, tax law, + and any law that applies to monitoring employees. + +The Customer receives a licence to use WorkTrack. **No ownership of the software +transfers.** All intellectual property in WorkTrack, including the source code, +the design, the APK signing identity, and the name "WorkTrack", remains with the +Vendor. + +## 5. The Vendor's obligations + +**a) Availability.** The Vendor will use **reasonable efforts** to keep the +hosted portal and API available and working. + +**There is no service level agreement in this Agreement.** The Vendor does not +commit to an uptime percentage, a maximum outage length, or a guaranteed +response time. If the Customer needs a commitment of that kind, it must be +negotiated and written into the Order Form; otherwise the Vendor is not offering +one and should not be understood to be offering one. + +The software is hosted on Google Cloud (Firebase). The Vendor depends on that +platform and on the Customer's internet connectivity, and is not responsible for +outages caused by either. + +**b) Support.** The Vendor will provide support by telephone and email during +ordinary working hours in Kabul: + +- Phone: +93 793 817 977 +- Email: contact@linumic.com + +These details are also shown inside the product, under **Settings → Support**. +The Customer should quote its company ID when contacting the Vendor. No +guaranteed response time applies unless one is written into the Order Form. + +**c) Updates.** The Vendor may update the portal and issue new versions of the +Android app during the licence period. Updates are included in the fee. The +Vendor does not promise any particular new feature by any particular date; +anything the Customer is relying on must be written into the Order Form. + +**d) Licence administration.** The Vendor will issue, renew, extend or adjust the +Customer's licence on request and on payment, normally within a working day. + +**e) Confidentiality.** See clause 11. + +## 6. The Customer's obligations + +**a) Accurate data.** WorkTrack calculates attendance, leave balances, payroll +and income tax from the data the Customer enters. The Customer is responsible +for the accuracy of that data — employee records, salaries, shifts, leave types, +holidays, and attendance corrections. + +Two points are worth stating explicitly, because they change payroll figures: + +- A working day with **no attendance record at all** is treated as unexcused + absence and is deducted from pay. +- **Public holidays count as working days unless they are entered** in the + portal under **Settings → Working calendar**. Nawroz and Independence Day are + fixed in the Solar Hijri calendar and are created for the Customer for the + first two years, at signup. From then on an administrator must set the year on + **Settings → Working calendar** and press **Generate this year's fixed + holidays** at the start of each year; if that is not done, those two days are + absent from the calendar and are deducted as unexcused absence like any other + unrecorded working day. Eid al-Fitr, Eid al-Adha, Ashura and Mawlid follow the + moon and are announced by sighting, so the Customer must add them by hand each + year. + +**b) Lawful use.** The Customer is responsible for using WorkTrack in a way that +complies with Afghan labour law, tax law, and any rules on employee monitoring. +This includes telling employees what is collected about them (see clause 9), +obtaining any consent the law requires, and paying its own taxes correctly. +WorkTrack calculates tax using published brackets; **it is not tax advice, and +the Customer remains responsible for its own tax filings.** + +**c) Credentials and devices.** The Customer must keep account passwords +confidential, give each user their own account, mark leavers as **Exited** and +reset their password (**Employees → Reset password**) so their old one stops +working — **there is no way to delete an employee account, and marking someone +Exited does not by itself disable their login** (see clause 10(c)) — and revoke +the device seats of phones and tablets that are lost, sold or retired. The +Vendor is not responsible for what someone does with credentials the Customer +failed to protect. + +**d) Administrators.** The Customer must appoint at least one company +administrator. Only a company administrator can close the company account +(clause 10). + +**e) Fees.** The Customer must pay the fees in clause 7. + +## 7. Fees and renewal + +**a) Fees.** The licence fee, the currency, the billing period and the payment +method are stated on the Order Form. **No prices are set in this document.** + +**b) Payment.** Invoices are payable by the date stated on the invoice. Unless +the Order Form says otherwise, fees are stated exclusive of any tax, duty or +bank charge, and the Customer pays those in addition. + +**c) Term and renewal.** The licence runs for the period on the Order Form. It +does **not** renew automatically. Before the expiry date, the parties should +agree a renewal and the Vendor will extend the licence. If no renewal is agreed, +the licence expires on its expiry date and clause 8 applies. + +**d) Adding seats.** The Customer may ask for more device seats at any time. The +Vendor will quote and, on payment, raise the limit. Reducing the seat count on a +renewal does not un-register devices that are already registered; it means the +next new phone is refused, subject to the two limits described in clause 3(b) — +the refusal is not explained to the employee, and a kiosk login is created even +when the seats are full. The Customer should revoke retired devices in the portal +to tidy the count. + +**e) Refunds.** Unless the Order Form says otherwise, fees already paid are not +refundable, including where the Customer stops using the software before the end +of the period. + +## 8. Suspension and expiry — what actually happens + +This clause describes the real behaviour of the software, so that neither party +is surprised. + +**a) Suspension for non-payment.** If an invoice is materially overdue, the +Vendor may set the Customer's licence status to **Suspended**, after giving the +Customer written notice (email is sufficient) and a reasonable chance to pay. + +**b) Expiry.** If the licence expiry date passes without renewal, the licence +stops being usable. + +**c) What suspension or expiry does.** In either case, and **only where +enforcement of the device limit is switched on for that licence**: + +- The **Android employee app and the kiosk screens stop working.** Check-ins can + no longer be filed from a phone or a kiosk. The product does not yet name the + licence as the reason: the server sends that explanation, but the Android app + shows a general permission error ("You don't have permission to do that.") and + the kiosk screen shows a general error ("Something went wrong"). The Customer + should tell its staff in advance what a suspension will look like. +- **The manager portal continues to work.** Managers, HR and finance staff can + still sign in, read existing records, run reports and manage employees. This + is deliberate: suspension is meant to stop new attendance being captured, not + to lock the Customer out of its own payroll history. +- **No data is deleted.** Suspension and expiry do not destroy anything. + +If enforcement is switched off for the licence, suspension or expiry has no +technical effect at all; it remains a contractual breach, but the software keeps +running. Whether enforcement is on is visible to the Customer under +**Devices & licence**. + +**d) Restoring.** On payment or renewal, the Vendor sets the licence back to +Active. Devices resume working. Because licence state is cached briefly on the +server, there can be a delay of up to about a minute before devices recover. + +**e) Termination for breach.** Either party may terminate this Agreement if the +other is in material breach and has not fixed it within 30 days of written +notice. On termination by the Vendor for the Customer's breach, the Vendor may +set the licence to Suspended with the effects described above. The Customer's +right to a copy of its data under clause 10 survives. + +## 9. Data — ownership and content + +**a) The Customer owns its data.** All data the Customer or its employees put +into WorkTrack — employee records, attendance, leave, shifts, salaries, payroll +runs, payslips, expenses, ledger entries, documents and settings — belongs to +the Customer. The Vendor claims no ownership of it and does not sell it, rent +it, or use it for advertising. + +**b) What the Vendor may do with it.** The Vendor processes the Customer's data +only to run the service, to provide support the Customer asks for, and to fix +faults. Vendor staff access a customer's records only when the Customer asks for +help or when it is necessary to fix a fault. The Vendor may use aggregated, +non-identifying operational information (for example, how many companies are +active) to run its own business. + +**c) What the software collects about employees.** The Customer should read this +and tell its staff, because some of it is personal data: + +- Name, contact details, job details, salary and bank/payment details as entered + by the Customer. +- Check-in and check-out times, and the method used. +- **GPS location** at the moment of **every** check-in made from the employee + app. Location is always captured and always stored with the attendance record. + The **Geofencing (GPS)** switch in **Settings → Features** does not currently + turn this off — it only affects whether a punch made outside a defined + geofence is flagged. The location is also checked against the Customer's own + geofences on the server. +- **A check-in selfie photo.** The database and the portal can hold and display + one, but **the employee app does not capture a check-in photo today** and + there is no setting to turn this on. No check-in photo is produced by the + product as supplied. +- **Device information** — a device identifier, the platform, the model and the + app version — for every registered phone and kiosk. +- **Face recognition is switched off by default** (`Settings → Features`). If the + Customer switches it on, the Android app computes a numeric face descriptor on + the phone itself and sends only that numeric vector. No face photograph is + sent to or stored on the server for face recognition. +- An internal audit trail of administrative actions (who changed what, and when). + +The Customer decides in **Settings → Features** which features to switch on, and +is responsible for the lawfulness of that decision. Two of the collections above +are **not** governed by that page and cannot be switched off there: GPS location +is captured on every check-in made from the employee app whatever the Geofencing +(GPS) switch is set to, and device information is recorded for every registered +phone and kiosk. The Customer should not tell its staff that turning Geofencing +off stops location being collected. + +**d) Where the data is held.** WorkTrack runs on Google Cloud (Firebase). The +application servers run in Google's `us-central1` region in the United States. +**The Customer's data is therefore stored and processed outside Afghanistan.** +The Customer should satisfy itself that this is acceptable for its own records +and for any rule that applies to it. + +**e) Security.** The Vendor uses the access controls, per-company separation and +role-based permissions built into the product, and relies on Google Cloud for +platform security. The Vendor does not claim any security certification and has +not been independently audited. No system is perfectly secure. + +## 10. Getting your data out, and deletion + +This clause describes what the product does today. Please read part (a) +carefully. + +**a) There is no self-service export.** WorkTrack has **no** button, screen or +API that exports the Customer's data to a file. There is no CSV export, no Excel +export, no PDF download of payslips, and no "download all my data" function +anywhere in the portal or in the app. Data can be read on screen in the portal +and printed from the browser, but it cannot be exported by the Customer alone. + +Accordingly, if the Customer needs a machine-readable copy of its data — on +termination, for an audit, for a tax inspection, or for any other reason — it +must **ask the Vendor**, and the Vendor will extract it manually from the +database and provide it in a common format (for example CSV or JSON). The Vendor +will do this within **30 days** of a written request, at no charge, once, on or +after termination. Requests during the licence period, or repeated requests, may +be charged at the Vendor's time-and-materials rate agreed in advance. + +The Customer should therefore make an export request **before** initiating the +account closure described in part (b), and should not assume it can retrieve its +own records after deletion. + +**b) Closing the company account.** A company administrator can close the +account from the portal, under **Settings → Close the company account**. The +software: + +1. Requires the administrator to **type the company name exactly** to confirm. +2. States plainly what will be destroyed: every attendance and leave record; + every payroll run, payslip and ledger entry; and the login of every employee + and kiosk. +3. Marks the account for closure and shows the date, in the Solar Hijri + calendar, on which the data will be deleted. +4. **Waits 30 days.** During those 30 days the closure can be cancelled from the + same screen with the **Cancel and reactivate** button, and everything comes + back untouched. +5. After the 30 days have fully elapsed, a scheduled job **permanently deletes** + the company and everything beneath it, and deletes the Firebase login of + every employee and every kiosk of that company. + +**Step 5 is irreversible. There is no undo, and the Vendor cannot restore the +data afterwards.** + +**c) Deletion of individual records.** The product does not provide a way to +delete or anonymise a single employee's historical attendance or payroll records +while keeping the rest. Their historical records remain, as payroll and labour +records normally must. + +An employee can be marked as **Exited**, which removes them from the attendance +board and from payroll runs. **It does not disable their login.** The product has +no way to disable or delete an employee account: an Exited employee can still +sign in to the Android app, punch in and out, and view their own records. To stop +a leaver signing in, an administrator must reset their password +(**Employees → Reset password**) so the old one no longer works, and revoke their +device under **Devices & licence**. + +**d) Backups.** The Vendor relies on the hosting platform's own durability. The +Vendor does **not** operate a separate, independently restorable backup of +customer data, and does not offer point-in-time restore. Deleted data is gone. + +**e) After termination.** Unless the Customer closes the account itself under +part (b), the Vendor will keep the Customer's data for **90 days** after the +licence ends, so that a renewal or an export request is still possible, and may +then delete it. The Vendor will give the Customer written notice before deleting +data under this part. + +## 11. Confidentiality + +Each party may learn confidential information of the other — for the Vendor, the +Customer's employee, payroll and business data; for the Customer, the Vendor's +pricing, technical design and non-public documentation. + +Each party will keep the other's confidential information confidential, use it +only for the purposes of this Agreement, and disclose it only to its own staff +and advisers who need it and who are under a duty of confidence. + +This does not apply to information that is public through no fault of the +receiving party, that the receiving party already lawfully had, or that must be +disclosed by law or by a competent authority — in which case the receiving party +will tell the other party first, if it is lawfully able to. + +These obligations continue for **three years** after this Agreement ends. The +Vendor's obligation in respect of the Customer's employee and payroll data +continues for as long as the Vendor holds that data. + +## 12. Warranties and disclaimer + +**a) The Vendor warrants** that it has the right to grant this licence, and that +the software will perform substantially as described in the documentation +supplied with it. If it does not, the Customer's remedy is to tell the Vendor, +and the Vendor will use reasonable efforts to fix the fault within a reasonable +time; if it cannot, the Vendor will refund the fee for the unexpired part of the +licence period. + +**b) Otherwise, the software is provided "as is".** To the fullest extent +permitted by Afghan law, the Vendor disclaims all other warranties, express or +implied, including any implied warranty of merchantability, fitness for a +particular purpose, uninterrupted or error-free operation, or that the software +will meet any requirement the Customer has not written into the Order Form. + +**c) The Vendor is not the Customer's accountant, tax adviser or lawyer.** +WorkTrack calculates payroll and income tax using the monthly brackets of +Article 4 of the Afghan Income Tax Law as the Vendor understands them. It is a +calculation tool. **The Customer remains responsible for the correctness of its +own payroll, its own tax withholding and its own filings**, and should have them +checked by a qualified accountant. Payroll runs for a month that has not ended +are marked "Provisional" and cover only the days elapsed so far; a provisional +run is not a final payroll. + +**d) Third-party platforms.** The Vendor does not warrant Google Cloud, Android, +the Customer's devices, or the Customer's internet connection. + +## 13. Limitation of liability + +**a) Nothing excluded that cannot be.** Nothing in this Agreement limits either +party's liability for fraud, for wilful misconduct, or for anything else that +Afghan law does not allow to be limited. + +**b) No indirect loss.** Neither party is liable to the other for indirect or +consequential loss, loss of profit, loss of business, loss of goodwill, or loss +of anticipated savings, however caused. + +**c) Cap.** Subject to (a), each party's total liability under this Agreement, +for all claims taken together, is limited to **the total fees paid by the +Customer under this Agreement in the 12 months before the event giving rise to +the claim**. + +**d) Data loss.** The Customer acknowledges that it, not the Vendor, controls +what is entered into WorkTrack, that the Vendor does not operate an independent +restorable backup (clause 10(d)), and that permanent deletion under clause 10(b) +is initiated by the Customer's own administrator. The Vendor is not liable for +data lost through the Customer's own use of the account-closure function, or +through the Customer's failure to request an export in time. + +## 14. Governing law and disputes + +**a) Governing law.** This Agreement is governed by the laws of the Islamic +Emirate of Afghanistan. + +**b) Language.** This Agreement may be issued in English, Dari and Pashto. The +parties should agree on the Order Form which version prevails if they differ. +*(Point for the lawyer: which language version governs, and whether an Afghan +court will accept an English-language contract, needs to be settled properly.)* + +**c) Good-faith discussion first.** If a dispute arises, the parties will first +try to resolve it by direct discussion between senior representatives, within 30 +days of one party notifying the other in writing. + +**d) If that fails.** If the dispute is not resolved, it will be submitted to +the competent courts of Kabul, Afghanistan, which will have exclusive +jurisdiction. + +*(Point for the lawyer: whether commercial arbitration in Kabul is preferable to +the courts here, and which arbitral body should be named, is a decision the +Vendor has not yet taken.)* + +## 15. General + +**a) Changing this Agreement.** This Agreement can only be changed in writing, +signed by both parties. **The Vendor cannot change these terms unilaterally**, +and posting new terms on a website does not change this Agreement. + +Prices, seat counts and the licence period are changed by agreeing a new Order +Form, which both parties sign; the new Order Form replaces the old one and this +document continues to apply. + +**b) Notices.** Notices must be in writing. Email is sufficient: to the Vendor at +contact@linumic.com, and to the Customer at the address on the Order Form. A +notice is treated as received on the next working day in Kabul. + +**c) Assignment.** The Customer may not assign or transfer this Agreement, or +its licence, without the Vendor's written consent. Consent will not be +unreasonably refused where the Customer's business is transferred as a whole. + +**d) Force majeure.** Neither party is liable for a failure caused by something +outside its reasonable control, including internet or power failure, an outage +at Google Cloud, natural disaster, or an act of government. + +**e) No partnership.** Nothing here makes the parties partners, or either the +agent of the other. + +**f) Entire agreement.** This document and the Order Form are the whole agreement +between the parties about WorkTrack, and replace anything said or written +beforehand. + +**g) Severability.** If any part of this Agreement is held to be invalid, the +rest continues in force. + +**h) Survival.** Clauses 4, 9, 10, 11, 12, 13 and 14 survive the end of this +Agreement. + +--- + +## Signatures + +| | Linumic | Customer | +|---|---|---| +| Name | | | +| Title | | | +| Signature | | | +| Date | | | +| Company ID | — | | + +--- + +# Gaps the Vendor must close, or must not promise + +**This section is addressed to Linumic, not to the customer.** It should be +removed before this document is issued, once each item is either fixed or +consciously accepted. It lists the places where a customer's reasonable +expectation and the product's actual behaviour do not yet meet. + +**1. There is no self-service data export. This is the biggest gap.** Nothing in +the portal, the API or the Android app produces a downloadable file — no CSV, no +Excel, no PDF payslip. A customer that closes its account and then asks for its +payroll history will need manual extraction from Firestore, done by hand, by +you. Until an export exists: +- Do not say "you can export your data" in any sales conversation. +- Keep clause 10(a) as written, including the 30-day manual commitment, and be + sure you can actually meet it. +- Build at least a CSV export for attendance, payroll runs and payslips. This is + also what a tax inspection will ask for. + +**2. The account-closure suspension is written but not enforced.** +`companyDeletion.ts` sets the company document's `status` to `SUSPENDED` when +closure is requested, and the code comment says nobody should keep filing +attendance into a tenant on its way out. Nothing in the API reads that field. +During the 30-day grace period the company keeps working normally. This draft +therefore does not claim the account is suspended during the grace period — +which is honest, but the code and its own comment disagree with each other, and +that should be fixed one way or the other. + +**3. Suspension for non-payment only bites if enforcement is on.** If +`enforceDevices` is false — which is the default for any company with no licence +on file — setting the licence to Suspended or Expired does nothing at all. Do +not tell a customer their access will stop unless you have actually issued them +a licence with `--enforce`. Check this before relying on suspension as a +collections tool. + +**4. Suspension never affects the manager portal.** `deviceGuard` applies only +to EMPLOYEE and KIOSK roles. A suspended customer's managers keep full access, +including running payroll. Clause 8(c) says so openly; decide whether that is +the commercial behaviour you want. + +**5. No independent backup.** You rely entirely on Firestore durability. There +is no export, no scheduled dump, and no point-in-time restore. Clause 10(d) and +clause 13(d) are written to reflect that. Do not promise backup or recovery to +anyone. A nightly export to Cloud Storage would close both this gap and gap 1. + +**6. Data is hosted in the United States.** Functions run in `us-central1`, and +the Firestore database is in a Google region, not in Afghanistan. Clause 9(d) +discloses this. If a customer — particularly a ministry, an NGO, or a bank — +requires data residency in Afghanistan, you cannot meet it, and must say so +before signing rather than after. + +**7. The audit trail is not readable by anyone.** `auditLogs` is written for +every significant action, and the RBAC catalogue grants `audit:read` to HR, +finance and auditor roles, but there is no API route and no portal screen that +reads it back. Do not promise an audit report. A customer who asks "who changed +this salary?" cannot be answered today without you querying the database +directly. + +**8. There is no per-employee data deletion or anonymisation.** Clause 10(c) +states this honestly. If a customer or a future regulation requires erasing one +person's records, the only tool is deleting the entire company. Worth building. + +**9. No SLA exists, and none should be implied.** This draft deliberately +commits to "reasonable efforts" and no numbers. Do not let a sales conversation, +an email, or a proposal put an uptime percentage or a response time in writing +unless you intend to be held to it and have the monitoring to prove it. + +**10. Marking an employee Exited does not stop them using the system.** The auth +middleware builds its context from the Firebase custom claims alone and never +reads the employee document's status; no route disables or deletes a leaver's +login. EXITED only filters them out of the attendance board and out of payroll. +An offboarded employee can still sign in, punch and sync until someone resets +their password. Clause 6(c) and clause 10(c) now say so. Build a real disable — +this is the offboarding step every customer will assume exists, and getting it +wrong leaves live accounts open. + +**11. The Geofencing (GPS) switch does not control location collection.** Every +self-service punch from the Android app is a GPS or FACE punch, coordinates are +mandatory for those methods, the geofence check always runs, and latitude, +longitude and accuracy are always written to the punch. `features.geofencing` is +read nowhere in the backend and nowhere in the app except when it is copied into +the session model. A customer can switch it off, tell its staff location is no +longer collected, and be wrong. Either make the switch stop collection or rename +it; clause 9(c) discloses the gap in the meantime. + +**12. Licence and seat-limit refusals are invisible to the user.** The API +returns proper messages ("This company's licence is not active", the seat-limit +refusal), but `ApiCall` maps every 403 to `AppError.PermissionDenied` and throws +the server's detail away, so the employee reads "You don't have permission to do +that."; the kiosk shows "Something went wrong". Support calls will arrive as +permission complaints, not licence questions. Surfacing the server's `detail` for +403 is a small change and worth making before suspension is used for collections. + +**13. Kiosk logins bypass the seat limit.** `createKioskAccount` writes the +device document directly with `active: true` — no licence read, no `countActive`, +no limit check. The limit is enforced only in `activateDevice`, which nothing but +the phone path reaches. A kiosk is therefore created over the limit and then +counts against it, refusing the next phone. Add the limit check, or keep the +disclosure now in clause 3(b). + +**14. There is no check-in selfie.** `SelfieCaptureRoute` exists but is +referenced from nowhere in the repo, no code path ever sets `PunchCommand.selfie`, +and there is no photo-verified check-in flag in the feature list. The field is +carried through the DTO, the model and the portal, so it looks built. Do not sell +photo-verified check-in; clause 9(c) now says the app does not capture one. + +**15. Fixed Solar Hijri holidays are only seeded for two years.** `signup.ts` +seeds `shamsiYear` and `shamsiYear + 1`; after that `seedSolarHolidays` runs only +when an administrator presses the button on the Working calendar card. A customer +in year three whose administrator forgets will see Nawroz and Independence Day +deducted as unexcused absence. Clause 6(a) now tells them to press it each year — +a scheduled job would be better. + +**16. Legal points still open, listed here so the lawyer sees them:** +- Which language version of the contract governs, and whether an Afghan court + will accept an English-language contract. +- Courts of Kabul versus commercial arbitration, and which arbitral body. +- Whether the liability cap in clause 13(c) is enforceable under Afghan law. +- Whether any Afghan rule governs the collection of employee GPS location — + which today happens on every app check-in and cannot be switched off — or of + face descriptors, and whether employee consent must be obtained in a + particular form. (The check-in selfie is not collected today; see gap 14.) +- Whether the transfer of employee personal data outside Afghanistan needs a + legal basis or a notification. +- Tax treatment of the licence fee, and whether withholding applies. + +**17. Fill in before issuing:** the customer's legal name, the Order Form +figures, and Linumic's own registration details (licence number, tax +identification number, registered address) if an Afghan commercial contract +requires them — which it very likely does. diff --git a/delivery/customer/05-privacy-notice-en.md b/delivery/customer/05-privacy-notice-en.md new file mode 100644 index 0000000..d8c2cb2 --- /dev/null +++ b/delivery/customer/05-privacy-notice-en.md @@ -0,0 +1,616 @@ +# WorkTrack Privacy Notice + +**This is not legal advice.** Linumic is a software vendor, not a law firm. +This notice describes, accurately and in plain language, what the WorkTrack +software actually collects, where it stores it, and who can see it. It is +written so that you and your lawyer have a truthful technical starting point. +Before you publish it to your staff or rely on it in a dispute, have a lawyer +who practises in Afghanistan review it and adapt it to your own contracts and +internal policy. + +Two audiences read this document: + +- **The company owner or manager** who bought WorkTrack. Sections 2 to 12 tell + you what your obligations are, because in law the data is yours, not ours. +- **The employee** whose attendance is recorded. Sections 4, 5, 6 and 11 tell + you what the app on your phone reads, what it sends, and what your employer + can see. + +Section 13 is a short list of things only you can fill in. Until you do, this +notice is incomplete. + +--- + +## 1. What this notice covers + +It covers the WorkTrack product as delivered to you: the Android employee app +(version 1.0.1), the manager portal at `https://worktrack-prod.web.app`, the QR +kiosk page, and the server behind them. + +It does not cover the marketing site `linumic.com`, and it does not cover the +public demo at `https://demo.linumic.com` — see section 12. + +--- + +## 2. Who is responsible for the data + +**Your company is the controller.** You decide to run WorkTrack, you decide who +is enrolled, you decide whether geofencing or face recognition is switched on, +and you decide what to do with the records. In law, the responsibility to your +employees is yours. If an employee asks what is held about them, or asks for it +to be corrected or deleted, they ask you. + +**Linumic is the processor.** We build and host the software and act on your +instructions. In practice that means: + +- We hold administrative credentials for the Google Cloud project that stores + your data. We can technically read it. We use that access for support, + incident investigation, and setup work that the portal does not expose. +- **Work areas (geofences) can only be created by us.** There is no screen or + API endpoint in the product to add one. If you want GPS check-in restricted + to a site, you send us the coordinates and radius and we write them into your + company's data directly. +- Licences are issued by us the same way. Your seat count, plan and expiry are + written with a vendor tool that runs against the Firebase project itself. + There is deliberately no way for you to change them — the endpoint was + removed on purpose. You see the licence read-only under **Devices & licence**. + +You should have a written processing agreement with Linumic that says this. +Ask for one if you do not have it. + +--- + +## 3. What WorkTrack stores about an employee + +This is the complete list, taken from the code. Every item below is stored on +the server, in your company's own area of the database. One internal record is +the exception and sits outside it — a nightly integrity report described in +section 9. + +### Identity and employment + +| Field | Where it comes from | +|---|---| +| First name, last name | Entered by an administrator under **Employees** | +| Employee code | Entered by an administrator | +| Email address | Entered by an administrator; also the login | +| Phone number (optional) | Entered by an administrator | +| Branch, department, position, manager | Entered by an administrator | +| Employment type (full time, part time, contract, intern) | Entered by an administrator | +| Join date | Entered by an administrator | +| Status (active, on leave, suspended, exited) | Set by an administrator | + +The login itself (email address and password) is held by Google Firebase +Authentication. WorkTrack never stores or sees the password — only Firebase +does, and only as a hash. + +There is an `avatarUrl` field on the employee record, but the product has no +way to upload a profile photo. It is always empty. + +### Attendance + +Every clock-in and clock-out is stored permanently as a separate record. It is +append-only: punches are never edited and never deleted, by anyone, including +us. Each one holds: + +- The employee it belongs to, and the exact time +- Whether it is an IN or an OUT +- The method: GPS, QR, FACE, MANUAL or KIOSK +- **Latitude, longitude and GPS accuracy in metres** — see section 4 +- Which work area it fell inside, and whether it was inside one +- Which kiosk it was scanned at, if any +- Whether the server accepted it, and if not, why (outside the work area, + device clock wrong, sent too late, impossible travel, invalid kiosk code) +- Whether the face check passed, when face recognition is switched on + +From these, the server computes one summary row per employee per day: first in, +last out, minutes worked, minutes late, minutes early, overtime, and the day's +status. That is what the **Attendance** page shows. + +### Leave + +- Leave type, start and end date, half-day flags, number of days +- **The reason the employee typed**, up to 1000 characters +- Status, who decided it, when, and their decision note +- Leave balances per year: entitled, used, pending, carried over + +### Attendance corrections + +When an employee asks to correct a day, the request stores the date, the times +they say are right, and **the reason they typed**. + +### Salary and payroll + +- Monthly basic salary, entered by an administrator under **Employees** +- For each payroll run: gross pay, deductions, net pay, income tax withheld + under Article 4 of the Afghan Income Tax Law, employer cost, cost to company, + days worked, paid leave days, unpaid absence days, and the individual pay + lines + +Payslips are kept indefinitely. Employees see their own in the app; they cannot +see anyone else's. + +### Devices + +For each phone that signs in, a device record holds a random identifier +generated by the app, the platform, the phone model, the app version, the +employee last signed in on it, and when it was last seen. The identifier is a +random value created on first run — **not** the IMEI, not the Android ID, not +the advertising ID, not anything tied to the handset or the SIM. Reinstalling +the app produces a new one. + +### Activity log + +Sensitive actions write an entry recording who did it, their role, what they +did, which record, and the before and after values. This includes settings +changes, employee edits, leave decisions, face enrolment and face resets, and +every punch. The punch entry records the method and whether it was accepted — +it does not record the coordinates. + +### What WorkTrack does not collect + +For the avoidance of doubt, and verified against the code: + +- No contacts, call log, SMS, photo gallery, microphone or file access. The app + declares six permissions: internet, network state, coarse location, fine + location, camera, and post-notifications. Only location and camera are ever + put to you in a dialogue; the rest are granted at install or never asked. +- No background location. The permission is not even declared, so the app + cannot read your position when it is not open on the check-in screen. +- No analytics or crash-reporting SDK. There is no Firebase Analytics, no + Crashlytics, no advertising SDK, no third-party tracker of any kind. +- No push notification service. The post-notifications permission is declared + in the manifest but unused — version 1.0.1 contains no notification code and + no push service, so nothing is ever sent to your phone. If an employee opens + the app's permission list in Android settings they will see notifications + listed there; it is inert. +- No national ID or tazkira number, no bank account details, no next of kin, + no health data, no disciplinary records. There is nowhere to put them. +- No profile photo upload. + +--- + +## 4. Location + +**When it is read.** Only while an employee has the check-in screen open in the +app, and only after they grant location permission. The app takes one +high-accuracy fix, waits up to 15 seconds, and will not accept a fix older than +10 seconds. It does not track movement, it does not sample on a timer, and it +cannot read location in the background. + +**What is stored.** Latitude, longitude and accuracy are saved on the punch +record and kept for as long as the punch is kept — which is permanently. The +server also records which work area the punch fell inside and whether it was +inside one. + +**How it is used.** The server, not the phone, decides whether the punch was +inside a work area. It compares the coordinates against the work areas defined +for your company, crediting GPS accuracy toward the radius. If your company has +no work areas defined, every location is accepted, but the coordinates are +still recorded. + +The server also compares each punch against the employee's previous one and +refuses it if the implied travel speed exceeds 250 km/h. + +**What managers can see.** The portal does not display coordinates and has no +map. It shows the outcome — accepted, or "Outside the work area". No screen and +no API endpoint gives a manager the stored coordinates: every route a manager +can call returns the day's totals or the check-in photo, never a latitude or a +longitude. An employee's own app can pull their own punches, coordinates +included, because it syncs its own records. For anyone else the only route is +Linumic querying the database directly on your written instruction. The +coordinates are held, and they are retrievable that way, so treat them as +recorded about the employee — but nobody in your company can pull them +unaided. + +**If location is refused.** The app says GPS check-in is not possible and +suggests the kiosk QR code instead. Kiosk punches attach a location only if a +fix happens to be available at that moment. + +--- + +## 5. Check-in photos + +The server will accept a small photo attached to a check-in, and the manager +portal has a **View check-in photo** button that appears when one exists. + +**As shipped, no photo is ever captured or sent.** The Android app version +1.0.1 contains a photo-capture screen, but nothing in the app leads to it — it +is unreachable code, and no check-in path attaches a photo. If you look at the +Attendance page you will never see the button, because there is nothing to +show. + +If a later version of the app wires that screen up, employees will start having +their photograph taken and stored on every check-in. That is a material change +and this notice must be updated before it ships. Ask Linumic to confirm in +writing before accepting any app update that enables it. + +--- + +## 6. Face recognition + +**It is off by default.** A new company is created with face recognition +switched off. It stays off until a company administrator turns it on under +**Settings → Features → Face recognition**. + +Read this section carefully before switching it on. A face template is +biometric data. In most data-protection regimes it is treated as a special +category requiring explicit, informed, freely given consent — and consent that +an employee cannot refuse without losing pay is legally fragile. This is a +decision to take with your lawyer, not a checkbox. + +### What actually happens when it is on + +The employee's check-in screen gains two extra buttons: **Enroll face** and +**Face check-in**. + +**Enrolment.** The employee points the front camera at their face. The phone +detects a face on-device using Google ML Kit. One frame is captured and cropped. +A machine-learning model bundled inside the app — MobileFaceNet, a 5.2 MB file +shipped in the APK — turns that crop into a list of 192 numbers. That list of +numbers is sent to the server and stored on the employee's record, together +with the time of enrolment. + +**The photograph is not sent and is not stored.** It exists in the phone's +memory during capture and is discarded. WorkTrack has no server-side storage +for a face image, no upload endpoint for one, and no code that writes one to +disk. This is verified in the code, not a claim from a brochure. + +**Verification.** At check-in the same capture happens, the same 192 numbers +are computed on the phone, and they are sent to the server. The server compares +them to the enrolled list mathematically (cosine similarity) and answers match +or no match. Again, no image is sent. On a match the server issues a signed +proof valid for 10 minutes; the check-in carries that proof, so the app cannot +simply claim it was verified. + +**Enrolment is once only.** An employee cannot silently re-enrol a different +face over their own record. Replacing an enrolment requires an administrator to +clear it under **Employees → Face → Reset**, which is permission-checked and +written to the activity log. + +**A failed face check does not void the check-in.** If face recognition is on +and someone checks in without a valid face proof, the attendance still counts. +The punch is flagged **Needs review** with the note "Recorded without face +verification", so a manager can look at it. This is deliberate: attendance — +and therefore pay — must not depend on a camera working. + +**Only GPS and face check-ins are flagged.** Kiosk QR punches and manually +entered punches are never marked Needs review, because they carry their own +proof of presence — a rotating code scanned at the kiosk, or an administrator's +own entry. This matters if you switch face recognition on expecting every +unverified check-in to surface: an employee who declines to enrol and uses the +QR kiosk instead will produce punches that are never flagged. + +### Honest limits you should know + +- **The match threshold has not been calibrated on real faces.** The code sets + it to 0.6 and carries a note from the developer saying it should be tuned + against real enrolment captures before a wide rollout. Until that is done, + expect some legitimate employees to be rejected, and do not treat a match as + proof of identity for any purpose beyond flagging a check-in for review. +- **What the stored numbers can be turned back into is not something the code + can tell you.** WorkTrack contains no function that reconstructs an image + from the stored vector, and none is shipped. Whether such a vector could be + reconstructed into a recognisable face by other means is outside what we can + verify from this product. Treat the stored vector as personal biometric data + and protect it accordingly. Do not tell your staff it is "just numbers and + therefore not biometric data" — that is the kind of statement that goes badly + in front of a regulator or a court. +- The activity log records that an enrolment happened and how many numbers it + contained. It never records the numbers themselves. + +### What we could not determine + +- Whether ML Kit's on-device face detector, which is a Google component, sends + anything to Google. WorkTrack sends it nothing and we found no such call, but + the internals of that library are not ours and we cannot make a promise on + Google's behalf. If this matters to you, raise it with your lawyer and with + Google's own terms for ML Kit. + +### If you decide to switch it on + +Do not do it silently. At minimum: tell your staff first, in writing, in a +language they read; explain that a numeric face template will be stored on the +server; offer a genuine alternative (the QR kiosk works without any face +capture); and record their consent. Section 13 lists this as something you must +write yourself. + +--- + +## 7. Where the data is stored + +WorkTrack runs entirely on Google Firebase. Data is stored in Google Cloud +Firestore, and logins are held in Firebase Authentication. Both are Google +services. + +**The server code runs in Google's `us-central1` region — Iowa, United States.** +That is set explicitly in the product's configuration. + +**The database's own location is set on the Google Cloud project and is not +recorded anywhere in the product's configuration.** We cannot tell you from the +code which region your Firestore data physically sits in. Ask Linumic to +confirm it in writing, and put the answer in section 13 before you publish this +notice. + +**In every case, the data leaves Afghanistan.** There is no on-premises option +and no Afghan hosting. If your company, a contract, or a donor agreement +requires data to stay in the country, WorkTrack as delivered does not meet that +requirement, and no setting changes it. + +Nothing is sent to any party other than Google. There is no third-party +analytics, advertising, or data-broker relationship in this product. + +--- + +## 8. Who can see what + +Access is decided by role, and refused by default. + +| Role | Can see | +|---|---| +| Company admin | Everything in the company | +| HR admin | Employees, attendance, leave, rosters, devices, announcements, payroll (read), activity log | +| Payroll admin | Employees, attendance, leave, and payroll — including running it | +| Branch manager | Their own branch's attendance, leave, rosters and devices — but see below on the employee directory | +| Team lead | Employees, attendance, leave (approve), rosters, announcements | +| Auditor | Read-only: employees, attendance, leave, payroll, activity log | +| Employee | Only their own attendance, leave and payslips, plus announcements | +| Kiosk | Only issues QR codes. It cannot read anyone's records. | + +There is one further role in the software, **Finance admin**, which owns expenses +and the general ledger. It cannot be granted to anyone in this version: the +**Employees** screen and the API both offer Employee, Team lead, Branch +manager, HR admin, Payroll admin and Auditor only. Do not go looking for it. +Finance, expenses and the ledger are visible to the company admin, who can see +everything. + +**Branch scoping is real on the attendance board, and absent on the employee +directory.** A branch manager or team lead who asks for another branch's +attendance board is refused, not quietly narrowed. The employee directory is +not scoped that way: anyone who can open **Employees** — branch manager, team +lead, and every administrator role — can read any employee record in the +company, whichever branch they belong to. That means name, employee code, +email, phone number, branch, department, position, join date, status and +whether a face is enrolled. Salary is not exposed this way; it needs the +payroll permission. If you were relying on branch boundaries to keep staff +contact details apart, they do not hold here. Ask Linumic if you need this +tightened. + +An employee cannot see another employee's attendance, salary or payslip through +the app or the API. + +No client application talks to the database directly. Every read and write goes +through the server, which checks the role first. + +--- + +## 9. How long data is kept + +**Indefinitely, unless the whole company account is closed.** WorkTrack has no +automatic deletion, no retention timer, and no archiving. Punches, attendance +days, leave records, payroll runs, payslips and the activity log stay until the +company is deleted. + +An employee who leaves is marked **EXITED**. Their record and their history +remain. There is no "delete this employee" function in the product — none in +the portal, none in the API. This is a real limitation, and you should know it +before you promise anything to a departing employee. + +The only automatic expiry in the product is on short-lived internal +housekeeping records (request replay keys and rate-limit counters), which hold +no personal information. + +**One internal record is kept outside your company's data.** A job runs +overnight to check that every attendance day was recalculated correctly. Where +it finds one that was not, it writes a report naming the company, the employee +id and the date. That report is stored in a shared area of the database +belonging to Linumic, not under your company. It has no expiry, and it is not +removed when your company account is purged. It holds no name, no salary and no +coordinates — an employee identifier and a date — but it is employee data and it +outlives everything else. Ask Linumic to delete these reports if that matters to +you. + +You should decide how long you actually need these records — Afghan employment +and tax practice generally requires payroll records to be kept for years — and +write that period into section 13. Recognise that the software will not enforce +it for you. + +--- + +## 10. What deletion actually does + +The only deletion the product offers is closing the entire company account: +**Settings → Close the company account**. + +1. An administrator types the company name back, exactly, to confirm. +2. The company is **marked as closing**. Be clear about what this does not do: + the software does not block anything during the 30 days. Employees can still + check in, managers can still approve leave, and payroll can still be run. The + mark is recorded on the company record and nothing reads it. If you need use + to actually stop, stop it yourself, or ask Linumic to suspend your licence — + that is what takes the phones offline. +3. **Nothing is destroyed for 30 days.** During that window any administrator + can cancel, and everything comes back untouched. +4. After 30 days a scheduled job destroys it. + +What the purge destroys, permanently and with no backup you can ask us to +restore from: + +- Every employee record, attendance punch and attendance day +- Every leave request and balance +- Every payroll run, payslip and ledger entry +- Every device record and the activity log +- The login of every employee and every kiosk + +This cannot be undone. There is deliberately no way to delete one employee, one +month, or one record type — the choice is the whole company or nothing. + +**What the purge does not reach.** The job deletes your company's own area of +the database and every login in it. It does not touch the nightly integrity +reports described in section 9, which live outside that area and carry an +employee id and a date. Those survive the purge. If you need them gone as well, +ask Linumic in writing, and do it as part of the same closure request. + +--- + +## 11. Employee rights, and how to exercise them in practice + +Your rights are against your employer, not against Linumic. Linumic holds the +data on their instructions and will not act on a request from you directly. + +**Access — what is held about me?** Ask your employer. Be aware of what the +product can and cannot do: there is no export button anywhere in WorkTrack. No +CSV, no PDF, no download. A manager can read your attendance on screen under +**Attendance**, your leave under **Leave**, and your payslips under **Payroll**, +and can copy that out by hand or by screenshot. Anything beyond that — your +stored coordinates, for instance — requires Linumic to query the database on +your employer's written instruction. Expect it to take time. + +**Your own view.** In the employee app you can see your own attendance history, +your own leave requests and balances, and your own payslips. That is a real +view of most of what is held about you, and it is the fastest route. + +**Correction of attendance.** You can file an attendance correction from the +app: give the date, the times you say are right, and a reason. A manager +approves or rejects it in the portal. The original punches are not changed — +they are never changed — but the day's totals are recomputed and that is what +payroll uses. + +**Correction of personal details.** Ask your employer. Name, email, phone, +branch, position and employment type are all edited by an administrator under +**Employees**. + +**Deletion.** Honestly: the product cannot delete one person. See section 9. +Your employer can close the whole company account, which deletes everyone. They +cannot delete only you. If you need your record removed, that is a conversation +with your employer about their retention policy, and it will have to be done by +Linumic against the database, if at all. + +**Face enrolment.** If face recognition is on and you have enrolled, ask an +administrator to clear it under **Employees → Face → Reset**. That removes the +stored template and the enrolment date from your record. It is logged. + +**Objection.** If you do not want your position recorded, do not grant the +location permission — the app will tell you to use the QR kiosk instead. +Whether refusing is practical, and what your employer does about it, is between +you and them. The software does not decide that. + +**Complaints.** Complain to your employer first, using the contact in section +13. If the complaint is about the software itself rather than your employer's +use of it, Linumic's details are in section 14. + +--- + +## 12. Security, and the demo site + +**Security measures actually in the product:** + +- All traffic runs over HTTPS to Google's front end. +- No client — not the app, not the portal, not the kiosk — can read the + database directly. Rules deny it outright; everything goes through the server, + which checks the caller's role. +- Each company's data lives under its own document tree, and the company is + taken from the signed login token, not from anything the caller sends. The one + exception is the nightly integrity report described in section 9, which is + written outside every company's tree and holds an employee id and a date. +- The Android app is excluded from Android cloud backup and device-to-device + transfer, so its local copy of your attendance and payroll data is not swept + into a Google backup. +- Employees can switch on a fingerprint or face lock for the app itself. That + uses the phone's own Android biometric prompt; WorkTrack never sees the + fingerprint or the face — the phone only answers yes or no. +- Face recognition can be switched on so that unverified GPS and face check-ins + are flagged for a manager. Kiosk QR and manual punches are not flagged — see + section 6. + +**Honest limitations:** + +- The app's local database on the phone is **not separately encrypted**. It sits + in the app's private storage, protected by Android's own sandbox and by the + phone's screen lock. On a rooted or compromised phone it is readable. Insist + that work phones have a screen lock. +- Sign-in is by email and password. There is no two-factor authentication in + the product. A manager account is only as strong as its password. +- The employee directory is not branch-scoped. Anyone who can open **Employees** + — including a branch manager or team lead — can read any employee's name, + code, email, phone, branch and status company-wide, not just their own branch. + Salary is not exposed this way. See section 8. +- Stored check-in coordinates are not reachable from any screen or any API + endpoint a manager can call, and an employee's app pulls only their own. They + are nonetheless held in the database, and Linumic can query them out on your + written instruction. They are protected by the absence of a route to them, not + by any access rule of their own. + +**The demo site.** `https://demo.linumic.com` is a public sandbox. It is +**reset and wiped every night**, and anyone on the internet can open it. Never +put a real employee's name, salary, phone number or face into it. It is for +looking at the product, nothing else. + +--- + +## 13. What you must complete yourself + +This notice is not usable until your company fills in the following. Until then +it is a technical description, not a privacy notice you can hand to staff. + +1. **Your company's own details.** The legal name of the controller, the + registered address, and the name, role, phone number and email address of + the person an employee should contact about their data. Put a real person + there, not "the office". + +2. **Your retention period.** How long you will keep attendance, leave and + payroll records after an employee leaves, and on what legal or tax basis. + Remember the software will not enforce it — you or Linumic will have to act. + +3. **Whether geofencing is on, and where.** List the sites, and tell your staff + that their coordinates are recorded at check-in. If you have not asked + Linumic to configure work areas, say so — location is still recorded, it is + simply not checked against anything. + +4. **Whether face recognition is on.** If it is, you must add: why you decided + to use it, what alternative an employee has (the QR kiosk), how you obtained + consent, and who to ask to have an enrolment cleared. If it is off, say so + plainly — most companies should leave it off. + +5. **The Firestore region.** Ask Linumic in writing which Google Cloud region + your database is in, and write the answer here. Confirm with your lawyer + whether storing employee data in that country is acceptable for your + contracts and any donor or client obligations you have. + +6. **Your internal policy.** Who in your company holds which role, who may run + payroll, who may view attendance for which branch, who may reset a face + enrolment, and what you will do when an employee asks to see or correct + their record. The roles you can actually assign are Employee, Team lead, + Branch manager, HR admin, Payroll admin and Auditor, plus the company admin + account itself; Finance admin is defined in the software but cannot be + granted in this version. Remember that a branch manager's limits apply to the + attendance board but not to the employee directory (section 8). Roles in + WorkTrack enforce the technical side. They do not write your policy. + +7. **The language you will publish this in.** Your staff read Dari and Pashto. + The app and the portal are fully translated into both. An English-only + privacy notice handed to staff who do not read English is not meaningful + notice. + +--- + +## 14. Contact + +**The vendor — Linumic**, Kabul, Afghanistan. + +- Phone: +93 793 817 977 +- Email: contact@linumic.com +- Web: linumic.com + +When you contact us, quote your company ID. You will find it in the portal +under **Settings → Support**, with a copy button next to it. Your licence is +issued against it, and every support conversation starts there. + +**Your company's own contact for data questions:** to be completed — see +section 13, item 1. + +--- + +*Prepared by Linumic for the WorkTrack handover pack. Every statement above was +checked against the source code of WorkTrack version 1.0.1. Where the code +could not answer a question, this notice says so instead of guessing.* diff --git a/delivery/play/README.md b/delivery/play/README.md new file mode 100644 index 0000000..ccbd911 --- /dev/null +++ b/delivery/play/README.md @@ -0,0 +1,27 @@ +# Play store assets + +Drag these into Play Console → Grow users → Store presence → Store listings → +Default store listing → Common visual assets. They cannot be uploaded from the +command line: Play builds the file input only when you click, which opens the +macOS file picker. + +| File | Slot | Size | +| --- | --- | --- | +| `icon-512.png` | App icon | 512×512 | +| `feature-graphic-1024x500.png` | Feature graphic | 1024×500 | +| `screenshot-0*.png` | Phone screenshots (need ≥2) | 1080×2400 | + +**The icon** is rendered from the app's own `ic_launcher_foreground.xml` on the +`#006874` launcher background, so the store and the phone show the same mark +rather than two drawings of it. + +**The feature graphic** is plain: brand navy `#004E72`, the same clock, and the +orange `#FF6D41` from the palette. It is honest and correct, not designed. If +the product ever gets a designer, this is the first thing to replace. + +**The screenshots** are the real app on a Pixel 8, signed in to the seeded demo +tenant (احمد کریمی at شرکت ساختمانی کابل) — not mockups. Note that +`screenshot-02-checkin.png` shows the geofence refusing a check-in 4,360 m from +the site, in red. That is the product's whole point and worth showing, but if +you would rather the store did not open on a warning, move the emulator inside +the fence and retake it. diff --git a/delivery/play/feature-graphic-1024x500.png b/delivery/play/feature-graphic-1024x500.png new file mode 100644 index 0000000..acfa4c7 Binary files /dev/null and b/delivery/play/feature-graphic-1024x500.png differ diff --git a/delivery/play/icon-512.png b/delivery/play/icon-512.png new file mode 100644 index 0000000..87c729b Binary files /dev/null and b/delivery/play/icon-512.png differ diff --git a/delivery/play/screenshot-01-work.png b/delivery/play/screenshot-01-work.png new file mode 100644 index 0000000..743ad71 Binary files /dev/null and b/delivery/play/screenshot-01-work.png differ diff --git a/delivery/play/screenshot-02-checkin.png b/delivery/play/screenshot-02-checkin.png new file mode 100644 index 0000000..1161456 Binary files /dev/null and b/delivery/play/screenshot-02-checkin.png differ diff --git a/delivery/play/screenshot-03-leave.png b/delivery/play/screenshot-03-leave.png new file mode 100644 index 0000000..4abb5d1 Binary files /dev/null and b/delivery/play/screenshot-03-leave.png differ diff --git a/delivery/play/screenshot-04-profile.png b/delivery/play/screenshot-04-profile.png new file mode 100644 index 0000000..42c48a7 Binary files /dev/null and b/delivery/play/screenshot-04-profile.png differ diff --git a/delivery/vendor/01-delivery-runbook.md b/delivery/vendor/01-delivery-runbook.md new file mode 100644 index 0000000..9def0f1 --- /dev/null +++ b/delivery/vendor/01-delivery-runbook.md @@ -0,0 +1,722 @@ +# WorkTrack delivery runbook + +From first enquiry to a working, licensed customer. This is for you, the vendor, +not for the customer. It assumes you have the repository, a terminal, and +credentials for the `worktrack-prod` Firebase project. + +Read the honesty box before you quote anyone. + +--- + +## Honesty box: what is manual, and where you are the bottleneck + +There is no vendor console. Everything below that is not the customer's own +self-service signup is you, at a terminal, one customer at a time. + +| Job | Who does it | How | +|---|---|---| +| Create the company | The customer, or you on their behalf | Portal signup form | +| Issue / change / renew / suspend a licence | **You only** | `set-license.js` against `worktrack-prod` | +| See the list of all customers | **You only** | `set-license.js --list` | +| Set a branch GPS geofence | **You only** | Hand-written document in the Firestore console — there is no UI and no API for this | +| Create a second branch | **Nobody** | Signup creates one head-office branch. The portal cannot add another | +| Reset the company admin's own forgotten password | **You only** | Firebase Authentication console for `worktrack-prod` — the sign-in screen has no "forgot password" link (section 6.7). Everyone else's password an admin can reissue themselves | +| Deliver a new app version | **You only** | Hand the APK over again. There is no in-app update | +| Invoicing and payment | Outside the product entirely | WorkTrack has no billing, no invoices, no payment | + +Consequences you should plan around: + +- Every licence action needs your laptop and your Google credentials. If you are + travelling without them, a customer who has just paid cannot be renewed. +- `--list` reads every company document in the project. It is your only + inventory. Keep your own record of who paid what and when — the product does + not track it. +- A licence change takes effect in the field within about a minute, not + instantly: the API caches a company's licence for 60 seconds per running + instance. + +--- + +## 0. What you need on your machine, once + +```bash +cd /Users/aminullahhashemi/StudioProjects/WorkTrack + +# Sign in to Google so the scripts can reach worktrack-prod. +gcloud auth application-default login + +# Compile the backend, including the licence tool. +npm --prefix backend/functions run build +``` + +`npm run build` compiles `backend/functions/src/**` into `backend/functions/lib/**`. +The licence tool ends up at `backend/functions/lib/scripts/set-license.js`. +Re-run the build after any `git pull`. + +Sanity check that credentials and project are right: + +```bash +cd /Users/aminullahhashemi/StudioProjects/WorkTrack/backend/functions +GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js --list +``` + +If that prints companies, you are ready. If it fails on credentials, re-run +`gcloud auth application-default login`. + +--- + +## 1. Qualifying the customer + +Ask these before you quote. Each one changes the number you sell or the work you +have to do. + +**How many employees?** +Employees are not what the licence counts, but they tell you the size of the +first-run session and whether payroll is realistic. Each employee needs a login +created by hand in the portal (Employees → Add employee), and the temporary +password is read out or written down — there is no invitation email. + +**How many phones will actually run the app?** +This is what you are selling. The licence grants *device seats*. One seat is +taken by each phone that signs in, identified by an id the app generates on +first run and keeps. Reinstalling the app on the same phone generally produces a +new id and therefore takes a second seat, so leave headroom. + +**Do they want kiosks, and how many?** +A kiosk is a tablet parked on the check-in screen with its own login, created in +the portal (Settings → "Kiosk devices" card → "Create kiosk login"; the card only +appears when the QR kiosk module is switched on). The "Kiosk" item in the menu is +the full-screen check-in display itself, not where logins are made. +**A kiosk consumes a device seat too.** +Sell seats as `phones + kiosks + headroom`. + +Be aware of a rough edge: creating a kiosk login does not check the seat count. +You can end up with more kiosks than the licence grants; the kiosks keep working, +but they inflate the count on Devices & licence and the next *phone* is refused. + +**Which branches, and do they want GPS restriction?** +Signup creates exactly one branch: "دفتر مرکزی" (code `HQ`). The portal has no +branch management and no geofence editor. If they want punches restricted to a +location, you write the geofence document yourself (section 5). If they have +several sites and want each fenced separately, that is several documents, all by +hand, and you must be honest that there is no screen where they can adjust it +later without calling you. + +**Android version on their phones.** +The app needs Android 8.0 or newer. Anything older cannot install it. + +**Anything you should say no to.** There is no iOS app. There is no biometric +fingerprint terminal integration. Face recognition exists but ships switched off +(`settings.features.faceRecognition`, default off) — do not sell it as a +delivered feature. + +--- + +## 2. Creating their company + +A tenant is created by one unauthenticated call: `POST /v1/public/signup`, which +runs `provisionCompany`. The portal's own sign-in page is the front end for it. + +**The customer can do this themselves.** At https://worktrack-prod.web.app the +sign-in screen has a link, "New company? Register". That opens a form asking for +Company name, Admin first name, Admin last name, Work email, Password (at least +8 characters), and the button reads "Create workspace". + +Prefer letting the customer do it, on their own machine, with their own email +address. You never handle their password that way. + +If you do it for them during a visit, have them type the password themselves. + +**What the founding admin gets.** One call creates, atomically: + +- The company, with all core modules on: shifts, leave, payroll, attendance + corrections, announcements, geofencing, QR kiosk. Face recognition is off. + Default policies: 480 standard daily minutes, Friday as the weekend, 10 + minutes late grace, overtime on. Timezone `Asia/Kabul`, currency `AFN`. +- One branch, "دفتر مرکزی", code `HQ`, with no coordinates and no radius. +- One shift, "شیفت روز", code `DAY`, 08:00–16:00, 60-minute break, 10 minutes + grace in and out. +- The founding admin as employee `E-001` with the `COMPANY_ADMIN` role — which + holds every permission inside the tenant. +- Two leave types: "رخصتی سالانه" (ANNUAL, 20 days) and "رخصتی مریضی" (SICK, 10 + days), with the admin's balances for the current year. +- The fixed Solar Hijri holidays for this Shamsi year and the next: Nawroz and + Independence Day only. Eid, Ashura and Mawlid follow the moon and are not + seeded — the customer adds those in Settings → Working calendar. + +**Email verification gates the account.** The signup form has Firebase mail a +verification link; the API refuses a self-signed-up admin until the address is +verified ("Verify your email address to finish setting up your company"). The +portal shows a "Verify your email" panel with a "Send the link again" button. +Tell them to check spam. Firebase sends this message itself — there is no other +mail transport configured anywhere in the product, so if the link does not +arrive there is nothing on your side to fix or resend. + +**What the new tenant's licence looks like before you do anything.** Nothing is +written. `getLicense` falls back to: plan FREE, 5 seats, status ACTIVE, no +expiry, **enforcement off**. Enforcement off means the seat limit is not applied +at all — the guard exits immediately — so an unlicensed tenant can run any +number of phones. The "5" you see under Devices & licence is cosmetic until you +issue a licence with `--enforce`. + +That is deliberate: a trial or pre-sale tenant gets a working product, not a +locked one. It also means **a customer who never pays keeps working until you +issue an enforced licence.** Do not skip section 3. + +Get the company id: they read it from Settings → Support ("Your company ID", +with a Copy button), or you find it with `--list`. + +--- + +## 3. Issuing the licence + +The licence is the thing the customer buys, so it is not something they can +grant themselves. There is deliberately no endpoint for it — writing a licence +requires credentials for the Firebase project, which only you have. Inside the +portal the licence is read-only, on Devices & licence, under the note: "Your +licence is issued by Linumic. To add device seats, extend the expiry date or +change your plan, contact us." + +All commands run from `backend/functions`: + +```bash +cd /Users/aminullahhashemi/StudioProjects/WorkTrack/backend/functions +``` + +### 3.1 Find the company id + +Either the customer reads it to you from Settings → Support, or: + +```bash +GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js --list +``` + +Each company prints as three lines: the id, the name, then its licence summary +(`plan=… seats=… status=… expires=… enforced=…`). A company with no licence on +file shows the defaults. + +### 3.2 Read what they hold today + +```bash +GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js \ + --company 01J8XYZ... --show +``` + +### 3.3 Dry run — always first + +The tool writes nothing unless you pass `--apply`. Run it once without, read the +`now:` and `next:` lines, and only then apply. + +Worked example: a 25-seat STANDARD licence expiring one year out, enforced. + +```bash +GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js \ + --company 01J8XYZ... \ + --plan STANDARD \ + --seats 25 \ + --expires 2027-09-07 \ + --status ACTIVE \ + --enforce +``` + +Output to expect: + +``` + project: worktrack-prod + + company: 01J8XYZ... (شرکت ...) + now: plan=FREE seats=5 status=ACTIVE expires=never enforced=no + next: plan=STANDARD seats=25 status=ACTIVE expires=2027-09-07 enforced=yes + + Dry run — nothing written. Re-run with --apply. +``` + +Check the `next:` line reads exactly what the customer paid for. Then: + +```bash +GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js \ + --company 01J8XYZ... \ + --plan STANDARD \ + --seats 25 \ + --expires 2027-09-07 \ + --status ACTIVE \ + --enforce \ + --apply +``` + +It prints `✓ Licence written.` + +### 3.4 Things to know about the flags + +- `--expires` is a **Gregorian** date, `YYYY-MM-DD`, or the literal word `never`. + The portal shows it as written. Expiry is judged against the company's own + calendar date in its timezone, so a licence does not lapse hours early in + Kabul. +- Anything you leave out keeps its current value. A renewal is therefore one + flag (`--expires`), and adding seats is one flag (`--seats`). +- `--status` defaults to ACTIVE only on a brand-new licence. On an existing one, + omitting it keeps whatever is there — so **un-suspending needs an explicit + `--status ACTIVE`.** +- `--enforce` / `--no-enforce` is what makes the seat count real. Without + `--enforce` the seat number is decorative. +- Lowering `--seats` below the number of devices already registered does not + un-register anyone. The tool warns you; registered devices keep working and + the next *new* one is refused. +- `--plan` is FREE, STANDARD or ENTERPRISE. The plan name is a label shown to + the customer (Free / Standard / Enterprise on Devices & licence). Nothing in + the code behaves differently per plan — seats, expiry and enforcement do all + the work. Do not promise plan-specific features. + +### 3.5 Verify + +```bash +GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js \ + --company 01J8XYZ... --show +``` + +Then have the customer open Devices & licence in the portal and read the five +facts back to you: Plan, Device limit, Status, Expires, Enforce the device +limit. The header chip shows "N of M devices". + +--- + +## 4. What to send them + +### 4.1 The app + +Release APKs are in `/Users/aminullahhashemi/StudioProjects/WorkTrack/app/release/`. +They are signed with the Linumic release key (RSA 4096, v2 and v3 signing; no v1, +which is correct because the app requires Android 8.0). Version 1.0.0, +versionCode 1. + +Every APK talks to the same production backend. **The APK is not +customer-specific** — the same file works for every customer, and the licence is +what separates them. + +| File | Size | Give it to | +|---|---|---| +| `app-arm64-v8a-release.apk` | 30 MB | Almost every phone sold in the last several years. **This is the default.** | +| `app-armeabi-v7a-release.apk` | 24 MB | Older, cheaper 32-bit phones | +| `app-x86_64-release.apk` | 33 MB | Emulators. Not a real phone | +| `app-universal-release.apk` | 80 MB | Only when you cannot find out what the phone is | + +Send arm64 first. If it refuses to install ("app not installed" / "package +appears to be invalid"), send armeabi-v7a. Only fall back to the universal build +if you are handing over a bag of unknown devices — it is 80 MB over a connection +that may not finish. + +Checksums, so they can confirm nothing was tampered with in transit: + +``` +0143556f689fef0c75d1bedf127361939823e4d128b4f8c8fabb030b40cf6359 app-arm64-v8a-release.apk +946fc3c66a9b7c7e33d2e3b3dad71c1f2fa5249925c6ec35e076f67e05240a15 app-armeabi-v7a-release.apk +4e27d52009a374070093e10fa3d8c10265969756b2f7b83e9031de4824e4218b app-x86_64-release.apk +4252c6682e7a2bb45053bd22f6d58d03711a2f3abfc6aeda9136e50e921f9064 app-universal-release.apk +``` + +Signing certificate SHA-256: +`e37a2ec8cdd024198dda6db7e97bb30ce03344a9bfbe47ab7db15280f4a7d983` + +Verify your own copy before sending: + +```bash +cd /Users/aminullahhashemi/StudioProjects/WorkTrack/app/release +shasum -a 256 *.apk +``` + +A technical customer can check the same on Windows with +`certutil -hashfile app-arm64-v8a-release.apk SHA256`. + +### 4.2 How to send it + +WhatsApp, Telegram, a USB stick, or a link you host. There is no Play Store +listing, so every phone must allow installation from that source once — Android +asks, and the employee taps "Allow" / "Install anyway". Warn them in advance so +the prompt does not read as a virus warning. + +### 4.3 The rest of the pack + +- The portal address: **https://worktrack-prod.web.app** +- The demo, if they want to show colleagues before rolling out: + **https://demo.linumic.com** (public, resets itself nightly, not their data) +- Support: **+93 793 817 977**, **contact@linumic.com**, Kabul. The same details + are inside the product at Settings → Support. +- Their company id, and the licence you issued in plain words: plan, number of + device seats, expiry date. +- The customer-facing guides. Check + `/Users/aminullahhashemi/StudioProjects/WorkTrack/delivery/customer/` and send + whatever is current — do not send a guide you have not opened. + +--- + +## 5. First-run support session + +Budget an hour on a call or in person, with the admin at a computer. Do these in +order; each one prevents a support call later. + +**1. Sign in and language.** https://worktrack-prod.web.app, their email and +password. The language switch is at the top of every page: دری / پښتو / English. +Set it to what they will actually use. + +**2. Settings → Company settings.** Walk through the three cards: + +- *Features* — turn off the modules they will not use. A disabled module + disappears from the menu. Leave Face recognition off. +- *Work policies* — Standard daily hours, Weekend day(s), Late grace (min), + Calculate overtime. Default weekend is Friday; change it if they work + Saturdays off instead. +- *Profile* — Currency and Timezone. Leave AFN and Asia/Kabul unless there is a + reason. + +Press "Save changes". + +**3. Settings → Working calendar. Do not skip this.** Say it plainly: *a working +day with no attendance record is treated as unexcused absence and is deducted +from pay.* Public holidays are only holidays if they are in this list. Nawroz +and Independence Day are already there. Eid al-Fitr, Eid al-Adha, Ashura and +Mawlid are set by moon sighting and are **not** seeded — the customer adds each +one with the "Add" button, every year. The card says this too. + +**Type the Gregorian date of the holiday into the Date box.** It is an ordinary +Gregorian date picker, and holidays are stored by Gregorian date. Once added, the +table shows the day in Solar Hijri with the Gregorian date underneath, so they can +check they got the right day. Only the "Year" box at the top of the card is a +Solar Hijri year. Typing a Shamsi date (1405-01-01) into the Date box books a +holiday six centuries out: the real day stays a working day and is deducted as +unexcused absence — the exact failure this step exists to prevent. + +The "Generate this year's fixed holidays" button only adds the two fixed ones for +the year in the Year box. + +**4. Employees → Add employee.** Add two or three together so they can do the +rest. For each: Code, Phone, Name, Name (2), Email, Employment, Join date, +**Monthly basic salary** and Role — the form warns that "Without a basic salary +this employee gets no payslip when payroll runs." There is no Branch field on the +form; there is only ever the one branch. Tick "Create a mobile-app login" +and leave the password blank to get a generated temporary one. A panel appears, +"Employee account created", with the email and temp password and a Copy button. +**That password is shown once.** If it is lost, open the employee → "Edit" → the +"Login account" field → leave it blank for another random password, or type one of +at least 8 characters → press "Set password". The new password is shown once in +the same "Employee account created" panel. Nothing anywhere is labelled "Reset +password" — the row has only an "Edit" button. Roles they can assign: Employee, +Team lead, Branch manager, HR admin, Payroll admin, Auditor. They cannot make +another company admin. + +**5. One phone, end to end, in the room.** Install the APK on one employee's +phone, sign in with the credentials from step 4, punch in, and then refresh the +Attendance page in the portal until the punch appears. Do not leave until you +have seen a punch made on a phone show up on the manager's screen. This is the +single most valuable thing in the session. + +If enforcement is on, this first sign-in is also what claims the phone's licence +seat — the app has no "activate device" screen; the seat is taken automatically +on the first request. It then appears under Devices & licence. + +**Location permission, on the 1.0.0 APKs you are handing over today.** Check-in +needs a location fix: with no location permission the punch buttons stay greyed +out and the screen reads "Location permission is required for GPS punch" +(«برای حاضری GPS اجازهٔ موقعیت لازم است»). In the 1.0.0 build the permission is +asked for in a way that Android 12 and newer drops silently — **no dialog +appears**, so on any recent phone you must grant it by hand: Settings → Apps → +WorkTrack → Permissions → Location → "Allow only while using the app". Do this on +every phone during the session; it is the most common "the app does nothing" +call. This is already fixed in the source — the next release asks properly and +the employee just taps "While using the app" ("Precise" or "Approximate", either +one works) — but it is *not* in the APKs in `app/release/`, so keep doing the +Settings step until you have built and sent a newer version. + +**6. Kiosk, if they bought one.** Settings → "Kiosk devices" card → "Create kiosk +login", give it a name like "Entrance kiosk". The card is only on the Settings +page when the QR kiosk module is on under Features; the "Kiosk" menu item is the +display itself and has no create button. You get an email and password once. Type +them into the tablet's browser at the same portal URL; the tablet then locks to the full-screen +"Scan to check in" display with a QR code that refreshes every 30 seconds. +Employees tap "Scan kiosk QR" in the app. Remember this tablet holds a seat. + +**7. Payroll, once.** Payroll → pick Year and Month → "Run payroll". Show them: +the run summary, the per-employee Gross / Deductions / Tax / Net, and the +"Provisional" badge if the month has not ended — "This month has not ended. The +figures cover only the days elapsed so far — run it again once the month closes." +Tax is the Afghan Income Tax Law Article 4 monthly brackets. If employees were +left out, the page says so and why: no basic salary on file. It warns separately +when someone whose status is "Exited" nonetheless worked during the month — they +are named, but they still get no payslip. Tell the admin the order matters: run +payroll first, mark the leaver as exited afterwards. If they did it the other way +round, set the person back to Active, run payroll again, then mark them exited. + +**8. Settings → Support.** Show them the phone number, the email, and their +company ID with its Copy button. Tell them to quote the company ID whenever they +call — it is what their licence is issued against. Note that only the company +admin sees Settings; an HR admin does not have the Settings menu at all. + +**9. GPS geofence — only if they asked, and only you can do it.** There is no +screen for this. You create the document yourself in the Firebase console for +`worktrack-prod`, under `companies//geofences/`: + +| Field | Type | Value | +|---|---|---| +| `companyId` | string | the company id | +| `branchId` | string | the branch id (the HQ branch created at signup) | +| `name` | string | e.g. `Head office` | +| `latitude` | number | e.g. `34.5553` | +| `longitude` | number | e.g. `69.2075` | +| `radiusMeters` | number | e.g. `150` | +| `active` | boolean | `true` | +| `updatedAt` | timestamp | now | + +Until at least one active geofence exists, no punch is rejected for location — +the server treats "no fences configured" as nothing to enforce. Once one exists, +a punch outside every active fence is refused and the employee sees "شما خارج از +ساحهٔ کاری مجاز هستید" ("You are outside the permitted work area"). GPS accuracy +is credited toward the radius, and being inside *any* fence is enough. Set the +radius generously — 150 m, not 30 m — or you will spend the next month on the +phone about it. + +Tell the customer honestly that they cannot change this themselves and must call +you to move or resize it. + +--- + +## 6. Renewal, seats, suspension — and what a phone in the field does + +### 6.1 What enforcement actually touches + +The device licence guard applies **only** when `enforceDevices` is on, and +**only** to employee phones and kiosk tablets. Managers work in a browser, and a +browser is not a licensed device — **the manager portal keeps working no matter +what you do to the licence.** So suspending a customer stops their staff +recording attendance; it does not lock the admin out of their data. + +Every check is cached for 60 seconds per running API instance, so any change +below reaches the field within roughly a minute, not instantly. + +### 6.2 Renewal + +```bash +cd /Users/aminullahhashemi/StudioProjects/WorkTrack/backend/functions + +GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js \ + --company 01J8XYZ... --expires 2028-09-07 # dry run + +GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js \ + --company 01J8XYZ... --expires 2028-09-07 --apply +``` + +Everything else keeps its current value. If the licence had already lapsed and +you had set `--status EXPIRED`, add `--status ACTIVE` as well. + +There is no expiry reminder anywhere in the product — not for you, not for them. +Put the renewal date in your own calendar the day you issue the licence. + +### 6.3 Adding seats + +```bash +GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js \ + --company 01J8XYZ... --seats 40 --apply +``` + +New phones can enrol immediately (within the cache minute). Nothing needs +reinstalling. + +If they have simply run out of seats because of retired phones, the cheaper fix +is free: the customer opens Devices & licence, finds the dead device by its +model and Last seen date, and presses "Revoke". That frees the seat at once. An +HR admin can do this too. "Restore" puts it back — though a restored device can +stay refused for up to a minute while the cached refusal expires. + +### 6.4 Suspending a customer who has not paid + +```bash +GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js \ + --company 01J8XYZ... --status SUSPENDED --apply +``` + +This only bites if `enforceDevices` is on. If you never issued an enforced +licence, `--status SUSPENDED` changes a label and nothing else. + +**What an employee with the app sees, precisely.** + +An employee who tries to sign in cannot: sign-in fetches their profile from the +API, the API refuses it, and the app drops the session and shows the generic +permission message — + +> دری: «اجازهٔ این کار را ندارید.» +> پښتو: «تاسو د دې کار اجازه نه لرئ.» +> English: "You do not have permission to do this." + +An employee already signed in keeps their app, their cached data and their +history on screen. Punching still *appears* to work: the punch is written +locally and queued. What fails is the sync — the queue stops draining and the +app reports the sync as failed. + +**Nothing is lost.** A refused sync requeues the whole batch untouched; queued +punches are never discarded. When you set the licence back to ACTIVE, the queue +drains on the next sync and the missing days appear in the portal. + +Be honest with yourself about the wording: **the app does not explain that this +is a licence problem.** It says "you do not have permission". The employee will +assume they have been fired, and the manager will call you. Tell the manager +before you suspend, so they can tell their staff. + +The same generic message is what an employee sees when the licence has expired, +when their device was revoked, and when the last seat has been taken by someone +else's phone. You cannot tell these apart from the phone — check Devices & +licence, or `--show`, to know which it is. + +A kiosk tablet behaves slightly differently: a kiosk whose device record was +revoked stays refused permanently and will not re-enrol itself. A phone will +re-enrol automatically once a seat is free. + +### 6.5 Un-suspending + +```bash +GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js \ + --company 01J8XYZ... --status ACTIVE --apply +``` + +Remember: status is *not* reset for you. If you also let the expiry lapse while +they were suspended, set `--expires` in the same command. + +### 6.6 The soft option + +If you would rather not stop attendance recording while a payment is chased, +turn enforcement off instead of suspending: + +```bash +GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js \ + --company 01J8XYZ... --no-enforce --apply +``` + +Everything works again with no seat limit. Nothing in the portal tells the +customer that this happened. + +### 6.7 A manager who has forgotten their portal password + +There is no self-service recovery. The sign-in screen offers email, password and +the "New company? Register" toggle — no "forgot password" link — and nothing in +the portal sends a password-reset mail. + +- **An employee's password** is not your problem: anyone who can edit employees — + the company admin, or an HR admin — reissues it from Employees → the employee → + "Edit" → "Login account" → "Set password". +- **The company admin's own password** is yours alone. You reset it in the + Firebase Authentication console for `worktrack-prod`: find the user by their + email address, reset the password there, tell them the new one and have them + change it. Until you do, they are locked out of the portal. + +There is no second company admin to fall back on: the portal cannot create one — +the assignable roles stop at HR admin. The founding admin's own email address and +password are the whole of the account's recovery, so say at handover that this is +a phone call to you, and that the address they sign up with should be one they +will keep and can still read. + +--- + +## 7. Offboarding + +### 7.1 The customer closes their own account + +Only the company admin can. Settings → "Close the company account" → "Close the +company account". The dialog lists what is destroyed — every attendance and +leave record, every payroll run, payslip and ledger entry, the login of every +employee and kiosk — and requires them to **type the company name exactly**, with +an optional Reason field for their own records. Then "Yes, close the account". + +The account is marked scheduled for closure and the card changes to "This account +is scheduled to close", showing the purge date: **30 days** from the request. +Until that date, "Cancel and reactivate" restores everything untouched. + +Set expectations honestly: during the grace period the account is flagged as +suspended internally, **but nothing is actually blocked** — the portal still +works, phones still work, and staff can keep punching into a company that is on +its way out. If they want it to stop before the purge, suspend the licence +(section 6.4). + +### 7.2 What the purge does + +A scheduled job runs at 04:00 Kabul time every day. For any company whose grace +period has fully elapsed it deletes the Firebase logins of every employee and +every kiosk, then recursively deletes the company and every subcollection under +it. It refuses to touch anything that is not an explicit, matured, scheduled +deletion. + +After that there is nothing to restore. There is no backup you can hand back. +Say this to the customer in those words before they type their company name. + +### 7.3 Your side + +There is nothing for you to run. Do not use the licence tool to "clean up" — it +only writes licences, it does not delete anything. After a purge, the company +simply stops appearing in `--list`. + +If a customer just stops paying and you want the tenant gone, you cannot do it +for them: closure is initiated from inside the portal by their own company +admin. Your only lever is the licence. + +--- + +## 8. Enquiry-to-delivery checklist + +Copy this per customer. + +``` +CUSTOMER: ______________________ DATE: __________ + +QUALIFY +[ ] Employee count: ______ +[ ] Phones running the app: ______ +[ ] Kiosk tablets: ______ (each one takes a seat) +[ ] Seats to sell = phones + kiosks + headroom: ______ +[ ] Branches: ______ GPS geofence wanted? Y / N + (only one branch exists; geofence is a manual Firestore edit by me) +[ ] Phones are Android 8.0 or newer: Y / N +[ ] Told them: no iOS app, face recognition not delivered + +CREATE +[ ] Company created at https://worktrack-prod.web.app -> "New company? Register" + (customer typed their own password) +[ ] Verification email opened, admin can sign in +[ ] Company id recorded: ______________________ + (Settings -> Support, or --list) + +LICENCE +[ ] cd backend/functions && npm run build (if not built today) +[ ] Dry run reviewed: + GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js \ + --company --plan STANDARD --seats --expires \ + --status ACTIVE --enforce +[ ] Applied with --apply, saw "Licence written." +[ ] --show confirms it +[ ] Customer read the five facts back from Devices & licence +[ ] RENEWAL DATE IN MY OWN CALENDAR: ______________ + (the product will not remind either of us) + +SEND +[ ] app-arm64-v8a-release.apk sent (fallback: armeabi-v7a) +[ ] Checksums sent +[ ] Portal URL, support phone/email, company id, licence terms in writing +[ ] Customer guides from delivery/customer/ sent (opened before sending) + +FIRST-RUN SESSION +[ ] Signed in, language set +[ ] Settings -> Company settings saved (features, policies, profile) +[ ] Working calendar: explained that a missing day = unexcused absence; + explained Eid/Ashura/Mawlid must be added by hand each year +[ ] 2-3 employees added WITH basic salary and mobile logins +[ ] Location permission granted by hand on each phone + (Settings -> Apps -> WorkTrack -> Permissions -> Location; needed on 1.0.0) +[ ] One real punch made on a phone and seen in the portal +[ ] Kiosk created and tested (if bought) +[ ] Payroll run once, "Provisional" explained +[ ] Settings -> Support shown, company id explained +[ ] Geofence document written by me (if wanted), radius >= 150 m + +AFTER +[ ] Told them: licence changes are a phone call to me, not self-service +[ ] Told them: app updates come from me by hand, no auto-update +[ ] Told them: closing the account has a 30-day grace period, then nothing + can be recovered +[ ] Told them: a forgotten admin password can only be reset by me, and a + second company admin is worth having +``` diff --git a/delivery/vendor/02-pricing.md b/delivery/vendor/02-pricing.md new file mode 100644 index 0000000..bd0e8af --- /dev/null +++ b/delivery/vendor/02-pricing.md @@ -0,0 +1,615 @@ +# WorkTrack pricing sheet + +For Linumic internal use. Not to be handed to a customer as-is. + +This sheet does two things. First it says exactly what the software enforces +today, so you never sell something the code cannot deliver. Second it proposes +a rate card built only on those real levers, plus the promises you would have +to keep by hand. + +Every price in this document is a proposal, not a validated market price. See +[Assumptions](#assumptions-read-before-you-quote-anyone). + +--- + +## 1. What the code actually enforces + +There is exactly one licence object per company, stored on the company document +under `license`. It is written only by +`backend/functions/src/scripts/set-license.ts`, run by you with project +credentials. There is deliberately no API endpoint to write it, so a customer +cannot change their own plan, seats, expiry or enforcement. + +The licence has five fields (`backend/functions/src/services/license.ts`): + +| Field | Values | What it actually does | +|---|---|---| +| `plan` | FREE / STANDARD / ENTERPRISE | **Nothing.** See below. | +| `deviceLimit` | 1–100,000 | Number of device seats. Enforced only when `enforceDevices` is true. | +| `status` | ACTIVE / SUSPENDED / EXPIRED | Anything but ACTIVE makes the licence unusable. | +| `expiresAt` | `YYYY-MM-DD` or null | Past date makes the licence unusable. Judged against the company's own Kabul date, not the server's. | +| `enforceDevices` | true / false | The master switch. When false, nothing above is enforced on day-to-day traffic. | + +### `plan` is a label and nothing else + +I searched the whole backend and the whole portal. `plan` is written, read, and +displayed. It is never used in a condition. Nothing branches on it — not a +feature, not a limit, not a route. + +- Backend: only `services/license.ts` (store/read), `scripts/set-license.ts` + (validate the string), and tests. +- Portal: `web/src/pages/DevicesPage.tsx` renders it as a read-only line on + **Devices & licence**, translated via `dev_plan_free` / `dev_plan_standard` / + `dev_plan_enterprise`. + +So **you cannot price on feature tiers today.** If you sell "Enterprise gets +the Finance module", nothing stops a Standard customer turning Finance on +themselves. Price on the things below instead. + +### The feature flags are the customer's, not yours + +`settings.features` (shifts, leave, payroll, regularization, announcements, +geofencing, qrKiosk, faceRecognition, finance) lives in +`backend/functions/src/services/settings.ts`. `PUT /v1/settings` requires +`settings:write`, and COMPANY_ADMIN holds `*` — so the customer's own admin can +toggle every one of these under **Settings → Features** ("Choose which modules +are enabled for your company. A disabled module is hidden from the menu."). + +That includes `faceRecognition`, which ships default OFF but is a checkbox the +customer can tick, and `finance`, which ships default ON. + +Consequence: **no module can be withheld for non-payment or sold as a paid +add-on with any technical backing.** You may still sell setup and training for a +module. You may not claim to switch it off from your side. + +### Seats are devices, not employees + +`activateDevice` and `enforceDeviceLicense` count **device documents** — +one per phone running the employee app, and one per kiosk account. Nothing +anywhere counts employees. There is no employee cap in the code, and +`set-license.ts --list` prints company id, name and licence, not headcount. + +This matters more than anything else on this page: + +- A 500-person factory where everyone punches at 10 shared kiosks needs + **10 seats**, not 500. +- A 20-person construction firm where everyone has the app needs **20 seats**. + +So seats measure your *infrastructure exposure*, not the customer's *value +received*. Price on headcount; use seats as the enforcement handle and the +anti-sprawl limit. If you price on seats, the factory pays a tenth of what the +construction firm pays for twenty-five times the value. + +### Two hard ceilings in the code — know these before quoting an enterprise + +- **Seat counting stops at 1,000 devices.** `countActive` and `listDevices` + both read `devices.limit(1000)`. `deviceLimit` accepts up to 100,000, but + above roughly 1,000 device documents the count is wrong and the Devices page + is incomplete. Do not sell more than ~1,000 seats without a code change. +- **The attendance board shows at most 500 employees.** `GET + /v1/attendance/overview` and the weekly view both cap the roster at + `employeesQuery.limit(500)`. A company with 600 active employees will see 500 + rows on **Attendance** and no warning. Disclose this before you take money + from anyone above 500, or fix it first. + +### What a company that has never had a licence issued gets + +Self-signup (`services/signup.ts`) writes **no `license` field at all**. So +`DEFAULT_LICENSE` applies: plan FREE, deviceLimit 5, status ACTIVE, expiresAt +null, **enforceDevices false**. + +Because enforcement is off, that nominal 5-seat limit is not enforced on +day-to-day traffic. A self-signed-up company has an unlimited, never-expiring, +fully-featured installation until you issue it a licence. That is a deliberate +choice (a pre-sale tenant gets a working product) but it means **a trial does +not end by itself.** You must issue a licence with an expiry to time-box it. + +--- + +## 2. What to sell + +Three tiers, defined by seats, support and services — not by features, because +features cannot be gated. + +| | Trial | Standard | Enterprise | +|---|---|---|---| +| Licence `plan` value to set | FREE | STANDARD | ENTERPRISE | +| Seats (`deviceLimit`) | headcount + 5 | agreed, +15% headroom | agreed, +15% headroom | +| `enforceDevices` | true | true | true | +| `expiresAt` | trial end date | paid-through + 14 days | paid-through + 30 days | +| Support channel | Email only | Phone + email, business hours | Phone + email + named contact | +| Target response | Best effort | Next working day | Same working day | +| Setup | Self-serve | Remote setup session | On-site setup in Kabul | +| Training | Written guides | 1 remote session | 2 on-site sessions | +| Data import (employee list) | No | Yes, one import | Yes, plus re-imports | +| Holiday calendar loaded for the year | No | Yes | Yes | +| Payroll dry-run reviewed with you | No | First month | First three months | + +### Enforced by code vs. promised by you + +| Line item | Enforced? | How | +|---|---|---| +| Device seat limit, phones | **Yes** | `deviceLimit` + `enforceDevices`, transactional seat count in `activateDevice` | +| Device seat limit, kiosks | **No** | `createKioskAccount` writes a device document without checking the licence; only phones are refused when the count is full | +| Licence expiry | **Yes** | `expiresAt`, checked in the company's Kabul date | +| Suspension | **Yes** | `status: SUSPENDED` → 403 for employee app and kiosks | +| Customer cannot raise their own limits | **Yes** | No write endpoint exists; script needs project credentials | +| Plan name shown in the portal | Cosmetic | Read-only line on Devices & licence | +| Any feature or module | **No** | Customer admin toggles them in Settings → Features | +| Employee headcount limit | **No** | Nothing counts employees anywhere | +| Support response time | **No** | Your promise. Nothing measures it. | +| Training, setup, import, on-site visits | **No** | Your promise | +| Backup / restore of one tenant | **No** | No per-tenant backup tool exists in the repo | +| Uptime or SLA | **No** | Only `/v1/health` exists; there is no monitoring or credit machinery | + +**The seat limit only bites on phones — read this before you quote seats.** +`createKioskAccount` (`backend/functions/src/services/kiosk-account.ts`) mints +the login and writes an ACTIVE device document without reading the licence at +all: no `getLicense`, no `licenseUsable`, no seat count. The route that reaches +it is gated only on `employees:write`, which the customer's own admin holds. The +device guard then waves those kiosks straight through, because the document +exists and is active. So a customer sold 22 seats can create 50 kiosk accounts +and every one of them will work. Only phones enrolling through the guard are +ever refused. A kiosk-heavy customer can quietly exceed the seat count they paid +for and you will not find out from the product — which is one more reason to +price on headcount, not on seats. + +Everything in the second half of that table is a promise you keep by hand. Price +your time into it — see the margin section, where your hours, not Firebase, are +the real cost. + +--- + +## 3. Proposed rate card (AFN) + +**Proposed. Not validated against the Afghan market. Read section 4 first.** + +Structure: a one-time setup fee, plus a per-employee monthly fee that steps down +with size, plus a monthly floor so a tiny customer still covers a support call. + +**Setup fee, one-time** + +| Company size | Setup fee (AFN) | +|---|---| +| Up to 50 employees | 15,000 | +| 51–200 | 35,000 | +| 201–1,000 | 75,000 | + +**Per employee, per month** + +| Headcount band | AFN per employee per month | +|---|---| +| First 25 | 120 | +| 26–100 | 90 | +| 101–300 | 70 | +| 301–1,000 | 50 | + +Bands are cumulative (like tax brackets), not a flat rate for the whole company. + +**Monthly floor:** 2,500 AFN/month. This is the one break-even figure in the +document, and everything else is derived from it: assume a support phone call +plus its follow-up costs you about an hour of your own time, and 2,500 AFN is +roughly what that hour has to earn. Below 2,500, a customer who phones you once +in the month has cost you more than the month brought in. + +**Annual prepay:** pay 10 months, get 12. Roughly 17% off. Recommended as the +default ask — collection is manual and every monthly invoice is a phone call you +have to make. + +### The three worked quotes + +Headcount drives price. Seats are set to what they actually need — remembering +that only the phone half of each seat count is actually enforced (section 2). + +**A. Construction company, 20 employees** + +Everyone carries a phone, one tablet at the site gate. + +| | | +|---|---| +| Monthly by band | 20 × 120 = 2,400 → floor applies | +| **Monthly** | **2,500 AFN** | +| Annual, paid monthly | 30,000 AFN | +| Annual, prepaid (10 for 12) | **25,000 AFN** | +| Setup fee | 15,000 AFN | +| **Year one, prepaid** | **40,000 AFN** | +| Effective per employee/month | 125 AFN | +| Seats to issue | 22 (20 phones + 1 kiosk + 1 spare) | + +**B. Factory, 100 employees** + +Most workers punch at shared kiosks; supervisors and office staff have phones. + +| | | +|---|---| +| Monthly by band | (25 × 120) + (75 × 90) = 3,000 + 6,750 | +| **Monthly** | **9,750 AFN** | +| Annual, paid monthly | 117,000 AFN | +| Annual, prepaid | **97,500 AFN** | +| Setup fee | 35,000 AFN | +| **Year one, prepaid** | **132,500 AFN** | +| Effective per employee/month | 98 AFN | +| Seats to issue | ~36 (25 phones + 6 kiosks, +15% headroom) | + +**C. Enterprise, 500 employees** + +| | | +|---|---| +| Monthly by band | (25 × 120) + (75 × 90) + (200 × 70) + (200 × 50) | +| | = 3,000 + 6,750 + 14,000 + 10,000 | +| **Monthly** | **33,750 AFN** | +| Annual, paid monthly | 405,000 AFN | +| Annual, prepaid | **337,500 AFN** | +| Setup fee | 75,000 AFN | +| **Year one, prepaid** | **412,500 AFN** | +| Effective per employee/month | 68 AFN | +| Seats to issue | ~106 (80 phones + 12 kiosks, +15% headroom) | + +**Before quoting case C, confirm the 500-employee board limit above.** At exactly +500 active employees the attendance board is at its cap; at 501 it silently +truncates. + +### Why the shape is this way + +- **Per employee, not per seat.** Value tracks headcount (payroll runs, + attendance days, absence deductions). Seats track only how many devices talk + to the server. A kiosk-heavy factory has few seats and enormous value; a seat + price would give it away. +- **Declining bands.** Your marginal cost per employee falls with size (see + section 6 — a 20-person company is entirely inside Firebase's free daily + quota), and a 500-person buyer will compare total AFN, not per-head AFN. +- **A floor, not a per-employee minimum.** The cost of a small customer is one + phone call, and that call costs the same whether they have 5 staff or 25. +- **Setup priced separately.** Employee import, the year's holiday calendar, + salary components, a payroll dry-run and a training session are real days of + your time. If you fold them into the monthly fee, a customer who leaves after + three months has taken those days for free. +- **Annual prepay pushed hard.** There is no billing system, no card gateway and + no invoicing anywhere in the product. Every renewal is you, on the phone. Twelve + collections a year for 2,500 AFN each is not a business. + +--- + +## 4. Assumptions — read before you quote anyone + +**I have no real Afghan market data.** None of the numbers above are grounded in +observed prices, salaries, or competitor quotes. They are internally consistent +and cost-covering; that is all. Every one of these is an assumption you must +replace with a checked fact: + +1. **Exchange rate: 70 AFN = 1 USD.** Used only to compare your AFN revenue + against Firebase's USD bill. Check today's rate; if AFN weakens materially, + your margin on a fixed AFN annual prepay shrinks over the year. +2. **Willingness to pay.** I assumed a Kabul SME will pay roughly one month of + one mid-level office salary per year for attendance and payroll software. I + have no evidence for this. **Check it against: what does a firm of this size + pay its HR/admin clerk per month?** If the annual fee exceeds two months of + that clerk's salary, expect resistance — the honest comparison a buyer makes + is "software vs. one more clerk". +3. **Competing products.** I do not know what local vendors charge, or what a + fingerprint attendance terminal plus its bundled desktop software costs in Kabul. + That hardware bundle is your real competitor and it is a **capital purchase, + not a subscription** — a buyer comparing a one-time 60,000 AFN device against + your recurring fee will do it on year-three total cost. Get three real quotes + before you finalise the rate card. +4. **Payment mechanics.** I assumed cash, hawala or bank transfer, collected by + you. There is no payment processing in the product. Confirm which of your + target customers can actually pay annually in advance — if most cannot, the + prepay discount is theatre and you should price monthly and expect to chase. +5. **Setup effort.** I assumed setup is 1–3 days of your time depending on size. + Time your first two real onboardings and reprice the setup fee from actual + hours. +6. **Support load.** I assumed a mature customer generates under one support + contact per month. If a 100-person factory generates one a week, case B is + priced too low — that is 4 hours a month of your only engineer. +7. **Retention.** The rate card assumes customers stay past year one. If they + churn after twelve months, the setup fee must cover nearly all of your + acquisition and onboarding cost, and 15,000 AFN will not. +8. **Firebase prices.** The unit prices in section 6 are as I understand them at + time of writing and Google changes them. Verify against the Firebase pricing + page and, more importantly, against your own bill for the first two months. + +**The three numbers to check first, before you quote a single customer:** the +local clerk salary benchmark (2), the biometric-terminal bundle price (3), and +whether annual prepay is collectable (4). + +--- + +## 5. Trial, discounts, non-payment + +### Trial + +The product has no trial mechanism of its own. Self-signup creates a company +with no licence, which means unlimited and never-expiring. **A trial only ends +if you make it end.** + +Recommended trial: 30 days, full product, licence issued on day one. + +``` +# Once per machine: sign in, so the script can reach worktrack-prod. +gcloud auth application-default login + +# From the repo root, after every git pull: the script is TypeScript and only +# exists under lib/ once it is compiled. +npm --prefix backend/functions run build + +# The command itself must be run from backend/functions. +cd backend/functions +GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js \ + --company COMPANY_ID --plan FREE --seats 25 --expires 2026-10-07 --enforce +# review the printed dry run, then repeat with --apply +``` + +Skip either of the first two steps and node exits with MODULE_NOT_FOUND or a +credentials error, not a useful message. Full prerequisites: see +01-delivery-runbook.md, section 0, "What you need on your machine, once". + +Notes that will save you a support call: + +- **Set seats generously.** With `--enforce` on, the trial company's phones + claim seats as they appear. Too small a number and staff are refused mid-trial + with "All N device seats on this licence are in use." +- Get the company id from the customer: it is on **Settings → Support**, labelled + "Your company ID", with a copy button. Or run the script with `--list`. +- Self-signup accounts are gated until the email address is verified. If the + customer says they cannot log in on day one, this is usually why. +- **Do not use demo.linumic.com as a trial.** It resets every night at 03:30 + Kabul time. It is a sales demo, not a sandbox they can put real data in. +- At trial end the licence lapses on its own. Employee phones and kiosks stop; + the manager portal keeps working (see below). Nothing is deleted. + +### Discounts + +| Discount | Amount | When | +|---|---|---| +| Annual prepay | 2 months free (~17%) | Default ask on every deal | +| Three-year prepay | 2.5 months free per year (~21%) | Only when you are confident the product will still be supported | +| NGO / school | 20% off the monthly fee | Your discretion | +| Reference customer | 15% off, in exchange for a named reference and permission to bring prospects on site | First 3–5 customers only | +| Setup fee | **Never discount** | It is paid labour, not margin | + +Set a walk-away floor and hold it: **1,500 AFN/month, or 50 AFN per employee per +month, whichever is higher.** This is not a second break-even point — break-even +is the 2,500 AFN list floor in section 3. 1,500 AFN is deliberately *below* it: +a customer at the walk-away floor loses you money in any month they phone you. +Take it only where the deal buys something other than cash — a named reference, +a first customer in a sector — never as an opening position, and never for a +customer you expect to be support-heavy. + +Stack no more than one discount. Do not discount the first year and plan to +raise the price at renewal — there is no billing system to enforce a step-up, +and the conversation will be worse than the discount was worth. + +### Non-payment + +You have exactly three levers, all in `set-license.ts`, and you must understand +what each one does and does not stop. + +**What suspension actually stops.** `enforceDeviceLicense` only applies to +EMPLOYEE and KIOSK callers, and only when `enforceDevices` is true. So: + +- The **employee Android app** stops: 403 with "This company's licence is not + active." +- **Kiosk screens** stop, same error. +- **The manager portal keeps working completely.** Managers can still log in, + view attendance, run payroll and export. There is no lever that locks the + portal. +- If `enforceDevices` is **false**, suspension and expiry do nothing at all — + not "only new activations". The guard returns at + `if (!license.enforceDevices)` before it ever reads the status or the expiry, + and no shipped client calls the `POST /v1/devices/activate` endpoint that + would otherwise check it (the Android app only stamps `X-Device-Id` on its + requests and lets the guard enrol it). **A licence issued without `--enforce` + is unenforceable.** Always issue with `--enforce`. +- Enforcement decisions are cached in-process for 60 seconds, so a suspension + takes up to a minute per warm server instance to take effect. Same on the way + back. + +**Recommended sequence** + +| Day | Action | +|---|---| +| 0 | Invoice due. Licence `expiresAt` was already set to paid-through + 14 days, so the clock is running whether you call or not. | +| +3 | Phone call. +93 793 817 977 is your number on their Settings → Support page; they will call you back on it. | +| +7 | Written notice by email to the admin address, stating the date access stops. | +| +14 | Licence lapses on its own — `expiresAt` passes. Phones and kiosks stop. Portal still works. | +| +21 | If still unpaid: `--status SUSPENDED --apply`. Same practical effect, but it is explicit and it shows as "Suspended" on their Devices & licence page. | +| Anytime | Payment received: `--status ACTIVE --expires --apply`. Devices reconnect within about a minute. Nothing was lost. | + +**What you must not do:** there is no vendor tool to delete a customer's data, +and you should not improvise one. Company deletion is customer-initiated only, +with a 30-day grace period before the nightly purge job acts. Data from a +non-paying customer simply sits there, costing you Firestore storage (pennies) +until they either pay or ask you to close the account. + +**Set the expiry every time you take money.** Renewal is one flag — same +directory, same build, same credentials as the trial command above: + +``` +cd backend/functions +GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/set-license.js \ + --company COMPANY_ID --expires 2027-03-20 --apply +``` + +Everything not passed keeps its current value. If you never set an expiry, a +customer who stops paying keeps a working product forever and your only remedy +is an explicit suspension you have to remember to run. + +--- + +## 6. Your cost side — Firebase Blaze + +One Firebase project, `worktrack-prod`, serves every customer. The API is a +single HTTPS function in `us-central1` (`minInstances: 0`, `maxInstances: 100`, +concurrency 80, 512 MiB). + +**The free daily quotas are per project, not per customer.** Firestore's free +50,000 reads / 20,000 writes per day are consumed by all your tenants together. +Your first customer is free; your fifth is not. + +### Unit prices assumed + +Verify these — Google changes them, and your Firestore location matters. + +| Item | Assumed price | +|---|---| +| Firestore document reads | $0.06 per 100,000 | +| Firestore document writes | $0.18 per 100,000 | +| Firestore stored data | $0.18 per GiB per month | +| Firestore free daily | 50,000 reads / 20,000 writes / 1 GiB | +| Cloud Functions (2nd gen) invocations | 2M free/month, then $0.40 per million | +| Hosting transfer | 360 MB/day free, then $0.15/GB | +| Cloud Scheduler | 3 jobs free; you use exactly 3 | +| Firebase Auth (email/password) | Free at your volumes | + +Blaze has no base fee. Your fixed monthly cost with zero customers is close to +zero — a few cents for Secret Manager holding `KIOSK_HMAC_SECRET`. + +### Which operations dominate — this is the important part + +I traced these in the code. In descending order: + +**1. The attendance board's 60-second refresh. This is your largest cost by a +wide margin.** + +`web/src/api/hooks.ts` refetches `/attendance/overview` every 60 seconds while a +manager has today's board open. Each call reads **every active employee plus +every attendanceDay for that date** (`routes/attendance.ts`, the two `.get()` +calls under `Promise.all`). For a 500-employee company that is roughly 1,000 +document reads per minute per open browser tab — about **1.4 million reads per +tab per 24 hours**. Four managers leaving the tab open all day is most of your +Firestore bill. + +It does refetch only while the tab is focused (`refetchIntervalInBackground: +false`), which helps a great deal. The weekly view behaves the same way. + +**2. Kiosk QR polling.** `useKioskToken` refetches every 20 seconds **including +in the background** (`refetchIntervalInBackground: true`) — a kiosk screen is +meant to sit there all day, so this is by design. Each poll is one function +invocation and one company-document read: **4,320 reads and 4,320 invocations +per kiosk per day**. Ten kiosks is 43,200 reads/day, and more importantly +1.3 million function invocations a month, which is most of the 2M free tier on +its own. + +**3. Employee app sync.** `WorkManagerSyncScheduler` runs every 30 minutes, and +each cycle pulls 12 resource types (`ResourceTypes.pullOrder`) — 13 Firestore +queries, since leave requests are queried twice. An empty query still bills one +read. So roughly **13–16 reads per phone per sync cycle**, call it 400–500 reads +per phone per day. Three hundred phones is around 144,000 reads/day. + +**4. Each punch.** `applyPunch` → geofence check → `recomputeAttendanceDay` +(punch window query + shift assignment query + shift document) ≈ **10 reads and +2–3 writes per punch**. Two punches per employee per day. At 500 employees that +is 16,000 reads and ~5,000 writes a day — still inside the free write quota. + +**5. Payroll runs.** `computePayrollRun` reads, per employee, the salary +document plus that employee's attendanceDays for the month (~26 docs), and +writes one payslip. For 500 employees: ~13,500 reads and ~500 writes **once a +month**. Negligible. Warn customers off re-running a provisional payroll +repeatedly for large companies, but it is not a cost problem. + +**6. Nightly integrity audit.** `runAttendanceAudit` reads two days of punches +for every company, every night at 02:00 Kabul. Small, and worth every read. + +### Estimated monthly infrastructure cost per customer + +Marginal cost, ignoring free tiers (because they are shared and the first +customer already ate them). + +| | A: 20 employees | B: 100 employees | C: 500 employees | +|---|---|---|---| +| Phones / kiosks assumed | 20 / 1 | 25 / 6 | 80 / 12 | +| Manager tabs open all day | 1 | 2 | 4 | +| Firestore reads/day | ~40,000 | ~245,000 | ~2,130,000 | +| Firestore reads/month | ~1.2M | ~7.4M | ~64M | +| **Firestore reads cost** | **~$0.70** | **~$4.40** | **~$38** | +| Writes | inside free tier | inside free tier | ~$0.30 | +| Function invocations/month | ~0.3M | ~1.2M | ~4.7M | +| **Functions cost** | ~$0 | ~$0–5 | **~$5–25** | +| Storage (no selfies) | <$0.05 | ~$0.10 | ~$0.50 | +| **Total, USD/month** | **~$1** | **~$5–10** | **~$45–65** | +| **Total, AFN/month at 70** | **~70** | **~350–700** | **~3,200–4,600** | + +The functions figure is the least reliable line: Cloud Run bills instance time, +which depends on how long instances stay warm. **Measure it on your real bill +after month one** and correct this table. + +### Margin + +| | A | B | C | +|---|---|---|---| +| Revenue AFN/month | 2,500 | 9,750 | 33,750 | +| Infrastructure AFN/month | ~70 | ~500 | ~3,900 | +| **Gross margin** | **~97%** | **~95%** | **~88%** | + +Infrastructure is not your cost. **Your time is.** One hour of your support +attention, valued at anything realistic, is worth more than a month of case A's +Firebase bill. Price and staff accordingly: the reason case A has a 2,500 AFN +floor is not servers, it is the phone. + +### Two things that will blow up your bill + +**Selfies stored in Firestore.** `punchCreateSchema` accepts a base64 selfie up +to 200,000 characters, stored inside the punch document, and copied onto the +attendanceDay as `checkInSelfie`. At 500 employees × 2 punches × 150 KB that is +about **6 GB of new storage per month, forever** — around $1/month in the first +month, $13/month by the end of the first year, and it never stops growing. It +also inflates every read of those collections. + +The board itself is safe: the overview deliberately sends only a +`hasCheckInSelfie` flag and fetches images on demand. But the storage growth is +real. If a customer turns on photo check-in for a large workforce, **watch the +Firestore storage line specifically**, and consider that a reason to price them +higher or to move images out of Firestore before you sell it. + +**Leaving the attendance board open.** See item 1 above. A single 500-employee +tenant with four permanently-open dashboards costs more in Firestore reads than +everything else that tenant does combined. If margin ever becomes a problem, the +cheapest fix in the whole product is raising that 60-second refresh interval or +making the board fetch only changed rows. + +### Cost items that are yours, not per-customer + +- The demo tenant (`worktrack-demo-af`) resets nightly and serves the public + sandbox at demo.linumic.com. Small, but it is marketing cost, not customer + cost. +- The Windows desktop shell (`desktop/`, WorkTrack-Setup-1.0.0.exe) is an + Electron window around the hosted portal. It is **not code-signed** — there is + no signing configuration in `desktop/package.json` — so Windows SmartScreen + will warn on install. Factor that into what you promise about desktop + installation, or budget for a code-signing certificate. +- The Android APKs are signed (RSA 4096, O=Linumic) and distributed by you + directly. There is no Play Store listing in this repo, so there is no store + fee and no store distribution. + +--- + +## 7. What the product does not do, that a buyer or you may assume it does + +State these plainly rather than discovering them at renewal. + +- **No billing, invoicing, or payment processing anywhere.** No card gateway, no + self-serve upgrade, no receipts. Every invoice and collection is manual. +- **No plan-change button for the customer.** By design. Every change is you and + `set-license.ts`. +- **No headcount reporting for you.** `--list` shows company id, name and + licence. It does not show how many employees a company has. If you price on + headcount you are on the honour system unless you query Firestore yourself. +- **No per-tenant backup or restore tool.** +- **No SLA machinery, no uptime monitoring** beyond an unauthenticated + `/v1/health` endpoint. +- **No iOS app.** The device schema accepts an `IOS` platform value, but the + only built clients are the Android APKs (signed; minSdk 26 / Android 8.0) and + the Windows portal shell (built, but **not** code-signed — see section 6). +- **No multi-company or group rollup.** Each company is a separate tenant with + separate logins. +- **Public holidays are the customer's job.** Only Nawroz and Independence Day + are seeded; the lunar holidays are set by moon sighting and must be entered in + **Settings → Working calendar** (Dari **تقویم کاری**, Pashto **کاري جنتري** — + there is no card called "Holidays", so do not send a customer looking for + one). The button that seeds the fixed days for a year is **Generate this + year's fixed holidays** (**ساخت تعطیلات ثابت سال** / **د کال ثابتې رخصتۍ + جوړول**); everything else is typed in by hand. Days that are not entered count + as working days and staff are marked absent. This is the single most common + cause of a wrong first payroll. Loading the year's calendar during setup is a + real part of what the setup fee buys. diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/desktop/build/icon.png b/desktop/build/icon.png new file mode 100644 index 0000000..62d9066 Binary files /dev/null and b/desktop/build/icon.png differ diff --git a/desktop/error.html b/desktop/error.html new file mode 100644 index 0000000..da51130 --- /dev/null +++ b/desktop/error.html @@ -0,0 +1,37 @@ + + + + +WorkTrack + + + +

+
W
+

اتصال برقرار نشد

+

به انترنت وصل نیستید یا پورتال در دسترس نیست. لطفاً اتصال خود را بررسی کنید.

+ +
Could not reach the WorkTrack portal — check your internet connection and retry.
+
+ + diff --git a/desktop/main.js b/desktop/main.js new file mode 100644 index 0000000..ffc374e --- /dev/null +++ b/desktop/main.js @@ -0,0 +1,77 @@ +// WorkTrack desktop shell: wraps the deployed company portal in a native +// window. The portal itself stays on Firebase Hosting, so web deploys reach +// desktop users without reinstalling. +const { app, BrowserWindow, shell } = require("electron"); +const path = require("path"); + +const PORTAL_URL = process.env.WORKTRACK_PORTAL_URL || "https://worktrack-prod.web.app"; +const PORTAL_ORIGIN = new URL(PORTAL_URL).origin; +// SMOKE=1 runs headless: load the portal, report, quit (used by CI/dev checks). +const SMOKE = process.env.SMOKE === "1"; + +let win; + +function createWindow() { + win = new BrowserWindow({ + width: 1280, + height: 800, + minWidth: 900, + minHeight: 600, + show: !SMOKE, + autoHideMenuBar: true, + backgroundColor: "#f4f6f8", + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + + // Keep the shell on the portal origin; everything else opens in the browser. + win.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url); + return { action: "deny" }; + }); + win.webContents.on("will-navigate", (event, url) => { + if (new URL(url).origin !== PORTAL_ORIGIN) { + event.preventDefault(); + shell.openExternal(url); + } + }); + + win.webContents.on("did-fail-load", (event, code, desc, url, isMainFrame) => { + // -3 = aborted (e.g. SPA navigation interrupted a load) — not an outage. + if (!isMainFrame || code === -3) return; + if (SMOKE) { + console.error(`SMOKE: failed to load ${url}: ${desc} (${code})`); + app.exit(1); + } + win.loadFile(path.join(__dirname, "error.html")); + }); + + win.webContents.on("did-finish-load", () => { + if (SMOKE && win.webContents.getURL().startsWith(PORTAL_ORIGIN)) { + console.log(`SMOKE: loaded ${win.webContents.getURL()}`); + app.quit(); + } + }); + + win.loadURL(PORTAL_URL); +} + +const gotLock = app.requestSingleInstanceLock(); +if (!gotLock) { + app.quit(); +} else { + app.on("second-instance", () => { + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); + } + }); + app.whenReady().then(createWindow); + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); + app.on("window-all-closed", () => app.quit()); +} diff --git a/desktop/package-lock.json b/desktop/package-lock.json new file mode 100644 index 0000000..5793d3c --- /dev/null +++ b/desktop/package-lock.json @@ -0,0 +1,5287 @@ +{ + "name": "worktrack-desktop", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "worktrack-desktop", + "version": "1.0.0", + "devDependencies": { + "electron": "^33.2.0", + "electron-builder": "^25.1.8" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.1.tgz", + "integrity": "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/rebuild": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.6.1.tgz", + "integrity": "sha512-f6596ZHpEq/YskUd8emYvOUne89ij8mQgjYFA5ru25QwbrRO+t1SImofdDv7kKOuWCmVOuU5tvfkbgGxIl3E/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "fs-extra": "^10.0.0", + "got": "^11.7.0", + "node-abi": "^3.45.0", + "node-api-version": "^0.2.0", + "node-gyp": "^9.0.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^6.0.5", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/@electron/rebuild/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/rebuild/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/rebuild/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/rebuild/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", + "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.7", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/fs/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "license": "ISC" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.10.tgz", + "integrity": "sha512-Ev4jj3D7Bo+O0GPD2NMvJl+PGiBAfS7pUGawntBNpCbxtpncfUixqFj9z9Jme7V7s3LBGqsWZZP54fxBX3JKJw==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-25.1.8.tgz", + "integrity": "sha512-pCqe7dfsQFBABC1jeKZXQWhGcCPF3rPCXDdfqVKjIeWBcXzyC1iOWZdfFhGl+S9MyE/k//DFmC6FzuGAUudNDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.1", + "@electron/rebuild": "3.6.1", + "@electron/universal": "2.0.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.9", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chromium-pickle-js": "^0.2.0", + "config-file-ts": "0.2.8-rc1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "25.1.7", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.0", + "resedit": "^1.7.0", + "sanitize-filename": "^1.6.3", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "25.1.8", + "electron-builder-squirrel-windows": "25.1.8" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "dev": true, + "license": "ISC" + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "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==", + "dev": true, + "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/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/bluebird-lst": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", + "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.5" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "25.1.7", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-25.1.7.tgz", + "integrity": "sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.10", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", + "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/cacache/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "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", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=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==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts": { + "version": "0.2.8-rc1", + "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz", + "integrity": "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.3.12", + "typescript": "^5.4.3" + } + }, + "node_modules/config-file-ts/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/config-file-ts/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", + "dev": true, + "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/config-file-ts/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-25.1.8.tgz", + "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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==", + "dev": true, + "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/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "33.4.11", + "resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz", + "integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-25.1.8.tgz", + "integrity": "sha512-poRgAtUHHOnlzZnc9PK4nzG53xh74wj2Jy7jkTrqZ0MWPoHGh1M2+C//hGeYdA+4K8w4yiVCNYoLXF7ySj2Wig==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "dmg-builder": "25.1.8", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-25.1.8.tgz", + "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "25.1.8", + "archiver": "^5.3.1", + "builder-util": "25.1.7", + "fs-extra": "^10.1.0" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "25.1.7", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", + "integrity": "sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "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==", + "dev": 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/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "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==", + "dev": true, + "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/foreground-child/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==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "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==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "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", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "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": "BSD-3-Clause" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "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==", + "dev": true, + "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/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/make-fetch-happen/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-api-version/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", + "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^12.13 || ^14.13 || >=16" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.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==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/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==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "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==", + "dev": true, + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-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==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar 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", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "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==", + "dev": true, + "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/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==", + "dev": true, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000..9046f4a --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,44 @@ +{ + "name": "worktrack-desktop", + "version": "1.0.0", + "description": "WorkTrack company portal — desktop shell for the manager web portal", + "author": "WorkTrack", + "main": "main.js", + "private": true, + "scripts": { + "start": "electron .", + "dist:win": "electron-builder --win --x64" + }, + "devDependencies": { + "electron": "^33.2.0", + "electron-builder": "^25.1.8" + }, + "build": { + "appId": "af.worktrack.portal", + "productName": "WorkTrack", + "files": [ + "main.js", + "error.html" + ], + "icon": "build/icon.png", + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ] + }, + "nsis": { + "oneClick": true, + "perMachine": false, + "artifactName": "WorkTrack-Setup-${version}.exe", + "deleteAppDataOnUninstall": false + } + }, + "allowScripts": { + "electron@33.4.11": true + } +} diff --git a/docs/00-master-spec.md b/docs/00-master-spec.md new file mode 100644 index 0000000..7726ce4 --- /dev/null +++ b/docs/00-master-spec.md @@ -0,0 +1,244 @@ +# WorkTrack — Master Specification (Source of Truth) + +> This document is the canonical reference for the WorkTrack platform. Every other design +> document, the Android codebase, the backend, and the web admin design derive from it. +> When a conflict arises between documents, this file wins; update it first. + +Version: 1.0 · Status: Approved · Owners: Platform Architecture + +--- + +## 1. Product definition + +WorkTrack is a multi-tenant Workforce Management Platform (HRMS) **built for +Afghanistan**: Dari (دری) is the default product language with full Pashto (پښتو) +and English translations, all dates and payroll periods follow the Solar Hijri +(هجری شمسی) calendar with Afghan month names, the weekend is Friday, and defaults +are AFN currency and the Asia/Kabul timezone. See `10-localization-afghanistan.md` +for the full localization architecture. The platform covers: + +| Domain | Capabilities | +|---|---| +| Identity & Org | Multi-company (tenant), multi-branch, departments, positions, employee lifecycle, RBAC | +| Attendance | GPS + geofence punch, QR kiosk check-in, face verification, shift-aware computation, overtime, regularization | +| Shift Scheduling | Shift templates, rosters, rotations, swap requests, open-shift claiming | +| Leave | Leave types, policies, accrual engine, balances, multi-level approvals, holiday calendars | +| Payroll | Salary structures, earning/deduction components, payroll runs, payslips, statutory rule hooks | +| HR Operations | Onboarding/offboarding checklists, documents, announcements, org directory | +| Analytics | Attendance/leave/payroll KPIs, trends, AI insights (absenteeism risk, overtime anomaly, attrition signals) | +| Platform | Audit logs, notifications, offline-first sync, device binding, enterprise security | + +Target scale: 1 → 100,000+ employees per tenant; thousands of tenants. + +### 1.1 Actors and roles + +Built-in roles (extensible via custom roles with permission sets): + +- `SUPER_ADMIN` — platform operator (cross-tenant, internal only) +- `COMPANY_ADMIN` — full control of one company +- `HR_ADMIN` — HR ops, employees, leave/attendance policy, payroll input +- `PAYROLL_ADMIN` — payroll runs, payslips, salary data +- `BRANCH_MANAGER` — scoped to branch(es): rosters, approvals, team analytics +- `TEAM_LEAD` — first-level approvals, team attendance visibility +- `EMPLOYEE` — self-service: punch, leave, payslips, profile +- `AUDITOR` — read-only + audit log access +- `KIOSK` — device role for QR kiosk terminals + +Permissions are strings `resource:action` (e.g. `attendance:approve`, `payroll:run`). +Roles are permission bundles; enforcement is server-side, mirrored client-side for UX only. + +--- + +## 2. Technology stack + +| Layer | Choice | +|---|---| +| Android | Kotlin 2.x, Jetpack Compose + Material 3, MVVM + Clean Architecture, Hilt, Room, WorkManager, DataStore, Navigation-Compose, ML Kit (QR + face), Play Integrity | +| Backend | Firebase Authentication (identity), Cloud Functions (Node 20, TypeScript, Express) exposing a versioned REST API, Firestore (system of record), Cloud Tasks (payroll jobs), Pub/Sub (fan-out), BigQuery export (analytics) | +| Web Admin | React 18 + TypeScript SPA (design in `06-web-admin-design.md`; implementation is roadmap Phase 4) | +| Infra | Firebase Hosting (admin SPA), Cloud Scheduler (accruals, roster locks), Cloud Storage (documents, face templates) | + +### 2.1 Tenancy model + +- Firestore layout: `companies/{companyId}/…` sub-collections per aggregate (see §4). +- Firebase Auth custom claims: `{ cid: companyId, r: [roleCodes], b: [branchIds], eid: employeeId }`. +- Every REST route resolves tenant from the verified ID token — never from the URL alone; URL companyId must match claim. + +--- + +## 3. Architecture overview + +``` +┌────────────┐ REST v1 (OIDC bearer) ┌─────────────────────────┐ +│ Android │ ─────────────────────────▶│ Cloud Functions (API) │ +│ offline-1st │ ◀───────── sync ──────────│ Express + middleware │ +└────────────┘ │ authn → tenant → rbac │ +┌────────────┐ └───────────┬─────────────┘ +│ Web Admin │ ──────────── same API ───────────────▶│ +└────────────┘ ┌─────────▼─────────┐ + │ Firestore │ + │ (system of record)│ + └─────────┬─────────┘ + Cloud Scheduler ──▶ jobs │ triggers + Cloud Tasks ──▶ payroll ▼ + Pub/Sub → BigQuery export → dashboards/AI +``` + +Principles: + +1. **Server-authoritative writes** for anything with money/compliance impact (attendance validity, leave balances, payroll). Clients propose; the server decides. +2. **Offline-first Android**: Room is the local source of truth; an outbox queue with idempotency keys pushes mutations; a delta-cursor pull applies server state. +3. **Append-only events** where possible (attendance punches, audit logs) — no conflict resolution needed. +4. **Versioned API** (`/v1`), additive evolution, explicit deprecation windows. + +--- + +## 4. Canonical data model + +Logical model in 3NF; maps to Room tables (client) and Firestore collections (server). +IDs are ULIDs (sortable, offline-generatable). All rows carry `companyId`, `createdAt`, +`updatedAt`, `syncStatus` (client-only), soft-delete `deletedAt`. + +### 4.1 Org & identity + +- **Company**(id, name, legalName, timezone, currency, status, plan, settingsJson) +- **Branch**(id, companyId, name, code, address, lat, lng, radiusM, timezone, status) +- **Department**(id, companyId, branchId?, name, code, parentDepartmentId?) +- **Position**(id, companyId, title, code, level, departmentId?) +- **Employee**(id, companyId, employeeCode, firstName, lastName, email, phone, avatarUrl, branchId, departmentId, positionId, managerId?, employmentType[FULL_TIME|PART_TIME|CONTRACT|INTERN], joinDate, exitDate?, status[ACTIVE|ON_LEAVE|SUSPENDED|EXITED], authUid) +- **RoleAssignment**(id, companyId, employeeId, roleCode, scopeType[COMPANY|BRANCH|DEPARTMENT], scopeId?) +- **Device**(id, companyId, employeeId, platform, model, appVersion, fcmToken, integrityVerdict, boundAt, revokedAt?) + +### 4.2 Attendance & scheduling + +- **Geofence**(id, companyId, branchId, name, lat, lng, radiusM, active) +- **Shift**(id, companyId, name, code, startTime, endTime, breakMinutes, graceInMinutes, graceOutMinutes, overtimePolicyJson, isNight, active) +- **ShiftAssignment**(id, companyId, employeeId, shiftId, date, branchId, source[ROSTER|ROTATION|MANUAL|SWAP], status) +- **ShiftSwapRequest**(id, companyId, requesterId, targetEmployeeId?, assignmentId, status, decidedBy?, decidedAt?) +- **AttendancePunch**(id, companyId, employeeId, punchedAt, type[IN|OUT], method[GPS|QR|FACE|MANUAL|KIOSK], lat?, lng?, accuracyM?, geofenceId?, insideFence, deviceId, kioskId?, faceScore?, photoUrl?, note?, serverValidated, invalidReason?) — **append-only** +- **AttendanceDay**(id, companyId, employeeId, date, shiftId?, firstInAt?, lastOutAt?, workedMinutes, breakMinutes, lateMinutes, earlyOutMinutes, overtimeMinutes, status[PRESENT|ABSENT|HALF_DAY|LEAVE|HOLIDAY|WEEK_OFF|PENDING], computedAt, version) — server-computed projection +- **RegularizationRequest**(id, companyId, employeeId, date, requestedInAt?, requestedOutAt?, reason, status[PENDING|APPROVED|REJECTED|CANCELLED], approverChainJson, decidedBy?, decidedAt?) + +### 4.3 Leave + +- **LeaveType**(id, companyId, name, code, colorHex, isPaid, requiresAttachment, active) +- **LeavePolicy**(id, companyId, leaveTypeId, accrualRule[NONE|MONTHLY|YEARLY|ANNIVERSARY], accrualDays, maxBalance, maxCarryover, minNoticedays, maxConsecutiveDays, appliesTo Json) +- **LeaveBalance**(id, companyId, employeeId, leaveTypeId, periodYear, entitledDays, accruedDays, usedDays, carriedOverDays, pendingDays, version) +- **LeaveRequest**(id, companyId, employeeId, leaveTypeId, startDate, endDate, startHalf, endHalf, days, reason, attachmentUrl?, status[DRAFT|PENDING|APPROVED|REJECTED|CANCELLED], approvalChainJson, currentApproverId?, decidedAt?) +- **HolidayCalendar**(id, companyId, name, year, branchIds Json) / **Holiday**(id, calendarId, date, name, isOptional) + +### 4.4 Payroll + +- **SalaryComponent**(id, companyId, name, code, type[EARNING|DEDUCTION|EMPLOYER_COST], calc[FIXED|PERCENT_OF_BASIC|PERCENT_OF_GROSS|FORMULA], value, formula?, taxable, statutoryCode?, active) +- **SalaryStructure**(id, companyId, name, componentIds Json) +- **EmployeeSalary**(id, companyId, employeeId, structureId, basicAmount, currency, effectiveFrom, effectiveTo?, revisionReason) +- **PayrollRun**(id, companyId, periodYear, periodMonth, branchIds Json, status[DRAFT|CALCULATING|REVIEW|APPROVED|PAID|CLOSED], startedBy, approvedBy?, totalsJson, lockedAt?) +- **Payslip**(id, companyId, runId, employeeId, periodYear, periodMonth, currency, gross, totalDeductions, net, workedDays, paidLeaveDays, lopDays, overtimeMinutes, status, pdfUrl?) +- **PayslipLine**(id, payslipId, componentCode, componentName, type, amount, meta Json) + +### 4.5 Platform + +- **Announcement**(id, companyId, title, body, audienceJson, publishAt, expiresAt?, createdBy, priority) +- **EmployeeDocument**(id, companyId, employeeId, kind, name, storagePath, mimeType, sizeBytes, expiresAt?, verifiedBy?) +- **AuditLog**(id, companyId, actorId, actorRole, action, resourceType, resourceId, beforeJson?, afterJson?, ip?, userAgent?, at) — **append-only, immutable** +- **NotificationMessage**(id, companyId, employeeId, kind, title, body, dataJson, readAt?, sentAt) +- **OutboxEntry** (client-only)(id, opType, resourceType, resourceId, payloadJson, idempotencyKey, attempts, lastError?, state[PENDING|IN_FLIGHT|DONE|FAILED], queuedAt) +- **SyncCursor** (client-only)(resourceType, cursor, lastSyncedAt) + +### 4.6 Firestore mapping + +`companies/{cid}` doc + sub-collections: `branches`, `departments`, `positions`, `employees`, +`roleAssignments`, `devices`, `geofences`, `shifts`, `shiftAssignments`, `punches`, +`attendanceDays`, `regularizations`, `leaveTypes`, `leavePolicies`, `leaveBalances`, +`leaveRequests`, `holidayCalendars`, `salaryComponents`, `salaryStructures`, +`employeeSalaries`, `payrollRuns`, `payslips`, `announcements`, `documents`, `auditLogs`, +`notifications`. Composite indexes on `(employeeId, date)`, `(status, updatedAt)`, `(updatedAt)`. + +--- + +## 5. REST API v1 (summary) + +Base: `https://api.worktrack.app/v1` · Auth: `Authorization: Bearer ` · +Idempotency: `Idempotency-Key` header honored on all POSTs · Errors: RFC 7807 problem+json · +Pagination: cursor-based `?cursor&limit` · Envelope: `{ "data": …, "meta": { cursor } }`. + +| Area | Endpoints | +|---|---| +| Session | `GET /me` (profile + roles + company), `POST /devices` (bind), `DELETE /devices/{id}` | +| Org | CRUD `/branches`, `/departments`, `/positions`, `/employees`; `POST /employees/{id}/deactivate` | +| Attendance | `POST /attendance/punches` (validate + persist), `GET /attendance/punches`, `GET /attendance/days?from&to&employeeId`, `POST /attendance/regularizations`, `POST /attendance/regularizations/{id}/decide` | +| Shifts | CRUD `/shifts`; `GET/PUT /rosters?branchId&from&to`; `POST /shift-swaps`, `POST /shift-swaps/{id}/decide` | +| Leave | `GET /leave/types`, `GET /leave/balances?employeeId`, `POST /leave/requests`, `GET /leave/requests`, `POST /leave/requests/{id}/decide`, `POST /leave/requests/{id}/cancel` | +| Payroll | `GET /payroll/runs`, `POST /payroll/runs` (async calc via Cloud Tasks), `POST /payroll/runs/{id}/approve`, `GET /payslips?employeeId&year`, `GET /payslips/{id}` | +| Comms | `GET/POST /announcements`, `GET /notifications`, `POST /notifications/{id}/read` | +| Analytics | `GET /analytics/kpis?scope&period`, `GET /analytics/insights` | +| Audit | `GET /audit-logs?resourceType&from&to` | +| Sync | `POST /sync/push` (batched outbox ops), `GET /sync/pull?types&cursor` (delta) | + +QR kiosk flow: kiosk displays rotating TOTP QR (`kioskId`, 30s window, HMAC signed); +employee app scans → `POST /attendance/punches {method:QR, kioskToken}` → server verifies +signature + window + kiosk branch vs employee branch. + +--- + +## 6. Android application + +### 6.1 Module graph + +``` +app + ├── feature:auth feature:dashboard feature:attendance + ├── feature:leave feature:payslips feature:profile + │ (feature:* → core:domain, core:designsystem, core:common) + ├── core:data ──▶ core:database, core:network, core:datastore, core:domain, core:model + ├── core:sync ──▶ core:data (workers, outbox processor, scheduling) + ├── core:domain ──▶ core:model, core:common (use cases + repository contracts) + ├── core:database / core:network / core:datastore ──▶ core:model, core:common + └── core:designsystem (M3 theme + components) core:common (Result, dispatchers, time) +``` + +Build logic lives in `build-logic/` convention plugins: +`worktrack.android.application`, `worktrack.android.library`, +`worktrack.android.library.compose`, `worktrack.android.feature`, `worktrack.android.hilt`, +`worktrack.android.room`. + +### 6.2 Navigation + +Root: `AuthGraph` (Login → ForgotPassword → DeviceBinding) → `MainGraph`. +Main scaffold: bottom bar with **Dashboard**, **Attendance**, **Leave**, **Profile**; +nested destinations: attendance history, punch flow (GPS/QR), leave apply/detail, +approvals inbox (role-gated), payslip list/detail, announcements, settings. +Deep links: `worktrack://leave/requests/{id}`, `worktrack://payslips/{id}`, `worktrack://approvals`. + +### 6.3 Offline & sync (client contract) + +1. All reads come from Room (`Flow`-based DAOs → repositories → use cases → UI state). +2. Mutations write Room optimistically (+`syncStatus=PENDING`) and enqueue an `OutboxEntry` with a ULID `idempotencyKey`. +3. `SyncWorker` (WorkManager, network-constrained, exponential backoff, unique work) drains the outbox FIFO-per-resource, then delta-pulls per resource cursor. +4. Server responses reconcile local rows (`syncStatus=SYNCED`, server fields win). +5. Punches are append-only: no update/delete ops exist client-side. +6. Conflict policy: server-authoritative; rejected ops surface as actionable notifications, never silent data loss. + +--- + +## 7. Security requirements (summary) + +- Firebase Auth + short-lived ID tokens; refresh handled by SDK; custom claims for tenant/RBAC. +- Server middleware chain: verify token → load tenant context → RBAC permission check → handler; deny-by-default. +- Firestore security rules: **no direct client access** to server-authoritative collections (all writes via API); rules act as second line of defense. +- Device binding + Play Integrity verdict required for punch endpoints; mock-location detection on-device (`isMock`) + server plausibility checks (speed-of-travel). +- Data: TLS 1.2+, at-rest encryption (Google-managed), tokens in EncryptedSharedPreferences/Keystore, no PII in logs, structured audit log for every privileged mutation. +- Face templates: stored as embeddings (not photos) in Cloud Storage with CMEK option; verification threshold server-tunable; raw capture deleted after embedding. +- Compliance posture: GDPR (DSR endpoints, retention policies), SOC 2 controls mapped in `07-security-architecture.md`. + +--- + +## 8. Delivery phases + +- **P0 (this repo, implemented)**: Android foundation — build-logic, core modules (common/model/database/network/datastore/domain/data/sync/designsystem), features (auth, dashboard, attendance, leave, payslips, profile), backend API core (auth/tenant/RBAC middleware, attendance punch + validation, leave requests + decisions, sync push/pull, payslip read), Firestore rules, full design docs. +- **P1**: Shift rosters UI, regularization, approvals inbox, face verification, kiosk app mode. +- **P2**: Payroll calculation engine + runs UI, statutory packs, document vault. +- **P3**: Web Admin SPA, analytics dashboards, BigQuery pipeline. +- **P4**: AI insights, attrition/absence prediction, anomaly detection, open APIs + webhooks. + +Details in `09-roadmap.md`. diff --git a/docs/01-product-requirements.md b/docs/01-product-requirements.md new file mode 100644 index 0000000..8f96ca4 --- /dev/null +++ b/docs/01-product-requirements.md @@ -0,0 +1,291 @@ +# WorkTrack — Product Requirements Document + +Version: 1.0 · Status: Approved · Owners: Product · Derives from: `00-master-spec.md` + +**Purpose.** This document translates the master specification into testable product requirements for the WorkTrack multi-tenant Workforce Management Platform. It defines the vision, target segments, personas, functional requirements per domain (with priority and acceptance criteria), the enterprise-hardening additions made beyond the original brief, non-functional requirements, and explicit scope boundaries. Where this document and `00-master-spec.md` diverge, the master spec wins. + +> **Priority key** — `P0` = must ship in the foundation release (maps to delivery Phase P0/P1), `P1` = required for enterprise sales readiness (Phases P2–P3), `P2` = differentiator (Phase P4). Requirement priority (P0/P1/P2) is orthogonal to delivery phase numbering (P0–P4 in `09-roadmap.md`); the phase column in each table states when the requirement is scheduled to land. + +--- + +## 1. Vision + +WorkTrack is the operational system of record for a distributed workforce: every punch, shift, leave day, and payslip flows through one auditable, offline-tolerant platform. It replaces the fragmented stack of biometric terminals, spreadsheets, and disconnected payroll tools with a single tenant-isolated platform that works for a 5-person shop and a 100,000-employee enterprise on the same codebase and the same API. + +Product pillars: + +1. **Truth over convenience** — server-authoritative computation for anything with money or compliance impact (attendance validity, leave balances, payroll). Clients propose; the server decides. +2. **Field-first** — the Android app is offline-first; a warehouse worker with no signal can punch, apply for leave, and read payslips, and the outbox reconciles later with zero silent data loss. +3. **Enterprise-honest** — audit immutability, RBAC, data residency, and DSR support are foundation features, not retrofits. +4. **One API** — Android, Web Admin, and third-party integrations consume the same versioned REST API (`/v1`); there is no privileged back channel. + +## 2. Target segments + +| Segment | Size | Buying trigger | Critical capabilities | +|---|---|---|---| +| SMB | 1–200 employees | Replace paper registers / WhatsApp attendance | GPS punch, simple leave, payslip PDF, single branch, self-serve onboarding | +| Mid-market | 200–5,000 | Multi-branch consistency, payroll input accuracy | Multi-branch geofences, shift rosters, approval chains, regularization, holiday calendars | +| Enterprise | 5,000–100,000+ | Compliance, audit, integration with ERP/IdP | RBAC with scoped roles, kiosk mode, statutory payroll hooks, audit export, BigQuery analytics, open API/webhooks, SSO/SCIM (future) | +| Platform operator (internal) | — | Operate thousands of tenants | `SUPER_ADMIN` tooling, per-tenant cost controls, plan management | + +Target scale (from master spec §1): 1 → 100,000+ employees per tenant; thousands of tenants. + +## 3. Personas (mapped to spec roles) + +| Persona | Role code(s) | Primary surface | Top jobs-to-be-done | +|---|---|---|---| +| Platform operator (internal SRE/support) | `SUPER_ADMIN` | Internal tooling / Web Admin | Provision tenants, investigate incidents cross-tenant, enforce plans | +| Company owner / COO | `COMPANY_ADMIN` | Web Admin | Configure company, branches, roles; see company-wide KPIs | +| HR manager | `HR_ADMIN` | Web Admin | Employee lifecycle, leave/attendance policy, regularization decisions, announcements | +| Payroll specialist | `PAYROLL_ADMIN` | Web Admin | Salary structures, payroll runs, payslip publication, statutory outputs | +| Branch/site manager | `BRANCH_MANAGER` | Android + Web Admin | Branch rosters, branch approvals, team attendance analytics | +| Shift supervisor | `TEAM_LEAD` | Android | First-level approvals (leave, regularization, swaps), team attendance visibility | +| Frontline employee | `EMPLOYEE` | Android | Punch in/out, view schedule, apply leave, read payslips, update profile | +| Internal/external auditor | `AUDITOR` | Web Admin | Read-only review, audit-log search and export | +| Kiosk terminal | `KIOSK` | Kiosk app mode | Display rotating TOTP QR for check-in; no human user | + +All roles are permission bundles over `resource:action` strings (e.g. `attendance:approve`, `payroll:run`); custom roles are composable from the same permission set. Enforcement is server-side; client-side mirroring is UX only. + +### 3.1 Key user journeys + +| # | Journey | Persona(s) | Path | Governing FRs | +|---|---|---|---|---| +| J1 | Morning punch-in, no connectivity | EMPLOYEE | Open app → Attendance → punch IN (GPS captured, stored in Room + outbox) → later sync validates geofence server-side | FR-ATT-001/003, FR-PLT-002 | +| J2 | Kiosk check-in at a shared site | EMPLOYEE + KIOSK | Kiosk shows rotating QR → employee scans in app → punch submitted with `kioskToken` → server verifies window/branch | FR-ATT-004 | +| J3 | Fix a missed punch-out | EMPLOYEE → TEAM_LEAD → HR_ADMIN | Attendance history shows PENDING day → raise RegularizationRequest → chain approves → AttendanceDay recomputed | FR-ATT-007, FR-LVE-003 pattern | +| J4 | Apply for leave with half-days | EMPLOYEE → approvers | Leave → balances → apply (startHalf/endHalf) → chain decides → balance moves pending→used → notification | FR-LVE-002/003/004 | +| J5 | Publish next month's roster | BRANCH_MANAGER | Rosters for branch → assign/rotate → PUT batch → employees notified; locked at T-N days | FR-SHF-002/003/006 | +| J6 | Run monthly payroll | PAYROLL_ADMIN → COMPANY_ADMIN | Create run → async calc (Cloud Tasks) → review exceptions → approve (SoD) → payslips + PDFs published | FR-PAY-002/003/004/006 | +| J7 | Investigate a suspicious punch pattern | HR_ADMIN / AUDITOR | Flagged punches (`invalidReason`) → audit log for the employee/device → device revocation if warranted | FR-ATT-005, FR-PLT-001, FR-ORG-005 | +| J8 | Offboard an employee | HR_ADMIN | Checklist completes → `POST /employees/{id}/deactivate` → claims cleared, devices unbound, roster future-cleared, history retained | FR-ORG-003, FR-HRO-004 | + +--- + +## 4. Functional requirements + +Conventions: requirement IDs are `FR--NNN`. Acceptance criteria (AC) are the minimum verifiable conditions; they assume the API contracts, entities, and error model of `00-master-spec.md` §4–§5. + +### 4.1 Identity & Org (FR-ORG) + +| ID | Requirement | Priority | Phase | Acceptance criteria | +|---|---|---|---|---| +| FR-ORG-001 | Tenant isolation: every record belongs to exactly one Company; no API call can read or write another tenant's data. | P0 | P0 | Token claim `cid` must match URL `companyId`; mismatch returns RFC 7807 `403`; verified by cross-tenant test suite. | +| FR-ORG-002 | Org structure: CRUD for Branch, Department (hierarchical via `parentDepartmentId`), Position via `/branches`, `/departments`, `/positions`. | P0 | P0 | Admin can create branch with geo (lat/lng/radiusM) and timezone; department tree renders without cycles (server rejects cyclic parent). | +| FR-ORG-003 | Employee lifecycle: create, update, deactivate (`POST /employees/{id}/deactivate`); statuses ACTIVE, ON_LEAVE, SUSPENDED, EXITED. | P0 | P0 | Deactivation revokes auth (custom claims cleared ≤ 60 s), unbinds devices, removes from future rosters; historical data retained. | +| FR-ORG-004 | RBAC: built-in roles per spec §1.1 plus custom roles as permission bundles; RoleAssignment scoped COMPANY, BRANCH, or DEPARTMENT. | P0 | P0 | A `BRANCH_MANAGER` scoped to branch B1 receives `403` on branch B2 resources; permission checks are deny-by-default. | +| FR-ORG-005 | Device binding: `POST /devices` binds one device per employee (configurable N); `DELETE /devices/{id}` revokes. | P0 | P0 | Punch from an unbound or revoked device is rejected with a machine-readable problem type; Device row stores `integrityVerdict`, `boundAt`, `revokedAt`. | +| FR-ORG-006 | Session bootstrap: `GET /me` returns profile + roles + company in one call. | P0 | P0 | Cold app start needs exactly one API call to render the authenticated shell. | +| FR-ORG-007 | Manager chain: `Employee.managerId` defines the reporting line used as default approval chain seed. | P0 | P0 | Changing a manager re-routes only future approvals; in-flight chains are unaffected. | +| FR-ORG-008 | SSO (OIDC/SAML) and SCIM provisioning for enterprise IdPs. | P2 | P4 | Employee created in IdP appears in WorkTrack ≤ 5 min; deprovisioning revokes access ≤ 5 min. | +| FR-ORG-009 | Custom roles: admins compose roles from `resource:action` permission strings; built-in roles are immutable templates. | P1 | P3 | Custom role creation requires `roles:manage`; deleting a role in use is blocked until reassignment; every role change is audit-logged with before/after permission sets. | +| FR-ORG-010 | Bulk import: CSV import for employees, departments, and shift assignments with dry-run validation. | P1 | P3 | Dry run reports per-row errors without writing; committed import is idempotent on `employeeCode`; import summary is audit-logged. | + +### 4.2 Attendance (FR-ATT) + +| ID | Requirement | Priority | Phase | Acceptance criteria | +|---|---|---|---|---| +| FR-ATT-001 | GPS punch: `POST /attendance/punches` with type IN/OUT, method GPS; server validates geofence containment and stores `insideFence`. | P0 | P0 | Punch inside fence → `serverValidated=true`; outside fence → persisted append-only with `insideFence=false` and `invalidReason` set; employee sees the outcome. | +| FR-ATT-002 | Punches are append-only; no client update/delete operations exist. | P0 | P0 | API exposes no PUT/DELETE on punches; corrections happen only via RegularizationRequest. | +| FR-ATT-003 | Offline punch: punch recorded in Room with outbox entry when offline; synced with original `punchedAt` and idempotency key. | P0 | P0 | Airplane-mode punch appears on server after reconnect exactly once (idempotent retry); `punchedAt` reflects capture time, not sync time. | +| FR-ATT-004 | QR kiosk check-in: kiosk (role `KIOSK`) displays rotating TOTP QR (30 s window, HMAC signed, carries `kioskId`); employee app scans and submits `{method:QR, kioskToken}`. | P0 | P1 | Server verifies signature + time window + kiosk branch vs employee branch; replayed or expired token rejected; clock-skew tolerance ±1 window. | +| FR-ATT-005 | Anti-spoofing: device binding + Play Integrity verdict required on punch endpoints; on-device mock-location flag (`isMock`) plus server speed-of-travel plausibility check. | P0 | P0 | Punch with failed integrity verdict or implausible travel (> configurable km/h between consecutive punches) is flagged `serverValidated=false` with `invalidReason`; surfaced to `HR_ADMIN`. | +| FR-ATT-006 | AttendanceDay computation: server-computed projection per employee/date (firstInAt, lastOutAt, workedMinutes, lateMinutes, earlyOutMinutes, overtimeMinutes, status) shift-aware including night shifts (`isNight`). | P0 | P0 | Recompute is deterministic and idempotent (`version` increments); grace windows (`graceInMinutes`/`graceOutMinutes`) applied; night shift spanning midnight attributes to the shift's start date. | +| FR-ATT-007 | Regularization: employee raises RegularizationRequest (requested in/out, reason); multi-level decision via `POST /attendance/regularizations/{id}/decide`; approval triggers AttendanceDay recompute. | P0 | P1 | Status transitions limited to PENDING→APPROVED/REJECTED/CANCELLED; approver chain honored (`approverChainJson`); recompute completes ≤ 60 s after approval. | +| FR-ATT-008 | Attendance history: `GET /attendance/days?from&to&employeeId` and `GET /attendance/punches`, RBAC-scoped (self, team, branch, company). | P0 | P0 | `EMPLOYEE` sees only self; `TEAM_LEAD` sees direct reports; cursor pagination; range capped server-side (≤ 92 days per query). | +| FR-ATT-009 | Face verification punch: embedding match against stored template, threshold server-tunable; raw capture deleted after embedding. | P1 | P1 | `faceScore` persisted on the punch; below-threshold match falls back per policy (reject or flag); no raw photo retained beyond embedding pipeline. | +| FR-ATT-010 | Overtime: computed from `overtimePolicyJson` on Shift; feeds `overtimeMinutes` into AttendanceDay and payroll. | P1 | P2 | OT below policy threshold is 0; OT rounding rule applied consistently; payslip OT equals sum of AttendanceDay OT for the period. | +| FR-ATT-011 | Day-status completeness: AttendanceDay status covers PRESENT, ABSENT, HALF_DAY, LEAVE, HOLIDAY, WEEK_OFF, PENDING; WEEK_OFF derives from roster gaps per policy, LEAVE from approved LeaveRequests, HOLIDAY from the branch calendar. | P0 | P1 | For any employee/date exactly one status is computed; precedence order (HOLIDAY > LEAVE > WEEK_OFF > punch-derived) is documented and test-covered; PENDING only while the day is incomplete. | +| FR-ATT-012 | Punch context: optional `note` and `photoUrl` on a punch (e.g. off-site client visit); photo capture policy per company. | P1 | P1 | Note length capped; photo uploaded via signed URL and linked before punch submission completes; photos excluded from face-verification pipeline. | + +### 4.3 Shift Scheduling (FR-SHF) + +| ID | Requirement | Priority | Phase | Acceptance criteria | +|---|---|---|---|---| +| FR-SHF-001 | Shift templates: CRUD `/shifts` (start/end, breakMinutes, grace windows, overtime policy, `isNight`). | P0 | P0 | Overlap and validity checks server-side; deactivating a shift does not alter historical ShiftAssignments. | +| FR-SHF-002 | Rosters: `GET/PUT /rosters?branchId&from&to` assigns shifts per employee per date (ShiftAssignment, source ROSTER/ROTATION/MANUAL/SWAP). | P0 | P1 | Bulk PUT is transactional per batch and idempotent; one active assignment per employee per date enforced; conflicts return per-item errors, not batch failure. | +| FR-SHF-003 | Rotation patterns: recurring patterns generate assignments ahead of time via scheduled jobs. | P1 | P1 | Generation window configurable (e.g. 28 days ahead); regeneration never overwrites MANUAL or SWAP assignments. | +| FR-SHF-004 | Shift swaps: `POST /shift-swaps` (targeted or open), `POST /shift-swaps/{id}/decide` by approver. | P1 | P1 | Approved swap atomically re-points both ShiftAssignments with source=SWAP; declined/expired swaps leave the roster untouched. | +| FR-SHF-005 | Open-shift claiming: unassigned roster slots are claimable by eligible employees, subject to approval. | P1 | P1 | Eligibility = same branch + position match + no conflicting assignment; first approved claim wins; losers are notified. | +| FR-SHF-006 | Roster locks: Cloud Scheduler locks rosters N days before the period; later changes require elevated permission. | P1 | P1 | Post-lock edits require `roster:override` permission and produce an AuditLog entry. | + +### 4.4 Leave (FR-LVE) + +| ID | Requirement | Priority | Phase | Acceptance criteria | +|---|---|---|---|---| +| FR-LVE-001 | Leave catalog: LeaveType (paid flag, attachment requirement) + LeavePolicy (accrual NONE/MONTHLY/YEARLY/ANNIVERSARY, maxBalance, maxCarryover, minNoticedays, maxConsecutiveDays, appliesTo). | P0 | P0 | Policy resolution is deterministic for any employee via `appliesTo` matching; exactly one policy applies per type per employee. | +| FR-LVE-002 | Apply for leave: `POST /leave/requests` with half-day support (startHalf/endHalf); computed `days` excludes holidays and week-offs. | P0 | P0 | Overlapping-request rejection; insufficient balance rejection (unless policy allows negative); attachment enforced when `requiresAttachment`. | +| FR-LVE-003 | Multi-level approval: `approvalChainJson` derived from manager chain and policy; `POST /leave/requests/{id}/decide` advances the chain; `.../cancel` by requester. | P0 | P0 | Only `currentApproverId` (or scoped admin) can decide; each hop notifies the next approver; full decision history retained. | +| FR-LVE-004 | Balances: LeaveBalance per employee/type/periodYear (entitled, accrued, used, carriedOver, pending) maintained server-side with optimistic `version`. | P0 | P0 | Applying moves days to `pendingDays`; approval moves pending→used; rejection/cancellation returns pending; balances never computed client-side. | +| FR-LVE-005 | Accrual engine: Cloud Scheduler applies accrual rules; year-end carryover honors `maxCarryover`. | P0 | P1 | Accrual job is idempotent per (employee, type, period); re-runs produce no double credit; audit entry per adjustment batch. | +| FR-LVE-006 | Holiday calendars: HolidayCalendar per year with branch mapping (`branchIds`); Holiday supports `isOptional`. | P0 | P1 | Attendance status HOLIDAY derived from the employee's branch calendar; leave-day computation skips holidays; optional-holiday elections capped per policy. | +| FR-LVE-007 | Leave visibility: `GET /leave/requests` and `GET /leave/balances?employeeId` RBAC-scoped; approvers see team calendars. | P0 | P0 | `TEAM_LEAD` sees direct reports' approved leave in schedule views; employees see own balances in ≤ 1 API call. | +| FR-LVE-008 | Optional-holiday election: employees elect from `isOptional` holidays up to a per-policy cap; elections feed attendance status. | P1 | P1 | Election window enforced; cap enforced per periodYear; elected day computes as HOLIDAY for that employee only. | +| FR-LVE-009 | Offline leave application: leave requests composed offline enter the outbox and sync with balance validation deferred to the server. | P0 | P0 | Offline-created request shows `syncStatus=PENDING`; server rejection (e.g. insufficient balance) surfaces as an actionable notification, and the request moves to a correctable failed state — never silently dropped. | + +### 4.5 Payroll (FR-PAY) + +| ID | Requirement | Priority | Phase | Acceptance criteria | +|---|---|---|---|---| +| FR-PAY-001 | Salary configuration: SalaryComponent (EARNING/DEDUCTION/EMPLOYER_COST; FIXED/PERCENT_OF_BASIC/PERCENT_OF_GROSS/FORMULA), SalaryStructure, EmployeeSalary with effective dating. | P0 | P2 | Overlapping EmployeeSalary effective ranges rejected; formula components validated at save time; every revision stores `revisionReason`. | +| FR-PAY-002 | Payroll run: `POST /payroll/runs` starts async calculation via Cloud Tasks; states DRAFT→CALCULATING→REVIEW→APPROVED→PAID→CLOSED. | P0 | P2 | Run over 100k employees completes ≤ 30 min; progress observable; failed employee calculations quarantined without failing the run; recalculation allowed until APPROVED. | +| FR-PAY-003 | Attendance/leave integration: workedDays, paidLeaveDays, lopDays, overtimeMinutes on Payslip derive from AttendanceDay and LeaveRequest projections for the period. | P0 | P2 | Payslip figures reconcile exactly with attendance data at run time; period locked (`lockedAt`) after approval — later regularizations route to the next run as arrears. | +| FR-PAY-004 | Payslips: PayslipLine per component; PDF rendered to Cloud Storage (`pdfUrl`); employee access via `GET /payslips?employeeId&year` and `GET /payslips/{id}`. | P0 | P2 | Employee sees only own payslips; payslip visible only after run APPROVED; PDF downloadable offline once cached. | +| FR-PAY-005 | Statutory rule hooks: SalaryComponent `statutoryCode` binds to pluggable per-jurisdiction statutory packs (e.g. PF/ESI/TDS-style rules) evaluated during calculation. | P1 | P2 | Statutory pack versioned; run records the pack version used; changing a pack never mutates historical payslips. | +| FR-PAY-006 | Approval & segregation of duties: `POST /payroll/runs/{id}/approve` requires `payroll:approve`; initiator (`startedBy`) cannot self-approve when SoD is enabled. | P0 | P2 | Approval writes `approvedBy` + AuditLog with totals snapshot (`totalsJson`); CLOSED runs are immutable. | +| FR-PAY-007 | Bank/export outputs: approved runs export payment register (CSV/SEPA-style) and GL summary. | P1 | P2 | Export totals equal run `totalsJson`; exports are audit-logged. | +| FR-PAY-008 | Arrears handling: attendance corrections approved after a run is locked (`lockedAt`) are carried as arrears lines into the next run, never retro-mutating issued payslips. | P0 | P2 | Post-lock regularization creates an arrears delta traceable to the source date; next run's payslip shows the arrears PayslipLine with `meta` referencing the origin period; issued payslips are immutable. | + +### 4.6 HR Operations (FR-HRO) + +| ID | Requirement | Priority | Phase | Acceptance criteria | +|---|---|---|---|---| +| FR-HRO-001 | Announcements: `GET/POST /announcements` with audience targeting (`audienceJson`), scheduling (`publishAt`), expiry, priority. | P0 | P0 | Only matching audience receives the announcement + push; expired items disappear from feeds; priority affects ordering and notification channel. | +| FR-HRO-002 | Notifications: `GET /notifications`, `POST /notifications/{id}/read`; FCM push for approvals, decisions, roster changes, payslip publication. | P0 | P0 | Every state transition that requires human action generates a NotificationMessage; read state syncs across devices. | +| FR-HRO-003 | Document vault: EmployeeDocument (kind, storagePath, expiry, verifiedBy) with signed-URL access. | P1 | P2 | Upload capped by type/size; expiring documents (visas, certifications) trigger reminders at T-30/T-7; access is RBAC-scoped and audit-logged. | +| FR-HRO-004 | Onboarding/offboarding checklists: templated task lists per position/branch tracked to completion. | P1 | P2 | Offboarding completion is a precondition for EXITED status; each task records completer + timestamp. | +| FR-HRO-005 | Org directory: searchable directory (name, position, department, branch) respecting field-level privacy settings. | P1 | P3 | Phone/email visibility configurable per company; search p95 < 500 ms at 100k employees. | + +### 4.7 Analytics (FR-ANA) + +| ID | Requirement | Priority | Phase | Acceptance criteria | +|---|---|---|---|---| +| FR-ANA-001 | KPIs: `GET /analytics/kpis?scope&period` — headcount, attendance %, late %, absenteeism, OT hours, leave utilization, payroll cost; scope respects RBAC. | P0 | P3 | KPI freshness ≤ 24 h (BigQuery-backed) or real-time where Firestore counters exist; `BRANCH_MANAGER` scope limited to assigned branches. | +| FR-ANA-002 | BigQuery pipeline: Firestore → BigQuery export feeds dashboards and AI; analytics queries never scan Firestore at company scale. | P0 | P3 | No analytics endpoint issues unbounded Firestore collection scans; BigQuery datasets are tenant-partitioned. | +| FR-ANA-003 | AI insights: `GET /analytics/insights` — absenteeism risk, overtime anomaly, attrition signals, with model explanation and confidence. | P2 | P4 | Insights are advisory and human-reviewable; per-tenant opt-out; no automated adverse action is taken from a model output. | +| FR-ANA-004 | Exports: KPI and audit exports to CSV; scheduled email digests for admins. | P1 | P3 | Export generation is async with notification on completion; exports are audit-logged. | + +### 4.8 Platform (FR-PLT) + +| ID | Requirement | Priority | Phase | Acceptance criteria | +|---|---|---|---|---| +| FR-PLT-001 | Audit log: append-only, immutable AuditLog for every privileged mutation (actor, action, resource, before/after, ip, userAgent); queryable via `GET /audit-logs?resourceType&from&to`. | P0 | P0 | No API mutates or deletes audit entries; `AUDITOR` role can read all; retention configurable ≥ 7 years for payroll-affecting actions. | +| FR-PLT-002 | Offline-first sync: `POST /sync/push` (batched outbox ops with idempotency keys), `GET /sync/pull?types&cursor` (delta). | P0 | P0 | 72 h fully-offline operation for employee flows; rejected ops surface as actionable notifications — never silent loss; push is idempotent under retry. | +| FR-PLT-003 | Idempotency: `Idempotency-Key` header honored on all POSTs; duplicate submission returns the original result. | P0 | P0 | Same key + same payload → identical response, no double effect; same key + different payload → `409` problem. | +| FR-PLT-004 | API platform: versioned `/v1`, additive evolution, explicit deprecation windows; RFC 7807 errors; cursor pagination with `{data, meta:{cursor}}` envelope. | P0 | P0 | Breaking change requires new version; deprecation announced ≥ 180 days ahead; every list endpoint paginates. | +| FR-PLT-005 | Webhooks & open API: tenant-configurable webhooks (HMAC-signed, retried) for employee/attendance/leave/payroll events; public OpenAPI spec + API keys for server-to-server integrations. | P2 | P4 | Webhook delivery ≥ 3 retries with backoff + DLQ; secrets rotatable; API-key scopes reuse `resource:action` permissions. | +| FR-PLT-006 | Rate limiting & abuse protection: per-token and per-tenant limits with `429` + `Retry-After`. | P0 | P0 | Sync endpoints have higher burst allowance; limits never drop punch data (client outbox retries); limits documented per endpoint class. | +| FR-PLT-007 | GDPR/DSR: data subject access export and erasure endpoints; retention policies per data class; erasure preserves financial/statutory records via pseudonymization. | P1 | P3 | DSR export delivered ≤ 30 days (target ≤ 72 h automated); erasure pseudonymizes PII while retaining payroll/audit integrity; every DSR is audit-logged. | +| FR-PLT-008 | Data residency: tenant data pinned to a declared region (Firestore/Storage/BigQuery location) at tenant creation. | P1 | P3 | Region immutable post-creation (migration = support process); backups and exports remain in-region. | + +--- + +## 5. Gaps identified in the original brief & added enterprise features + +The original brief ("attendance + payroll app with GPS punch") omitted capabilities that are mandatory for mid-market and enterprise deployment. This section records each gap, the resolution now embedded in the spec and requirements above, and where it lands. + +| # | Gap in original brief | Resolution in WorkTrack | Where | +|---|---|---|---| +| 1 | No correction path for missed/invalid punches | Attendance **regularization** workflow with multi-level approval and recompute (RegularizationRequest) | FR-ATT-007, Phase P1 | +| 2 | Single-approver assumption | **Approval chains** (`approvalChainJson`) for leave, regularization, swaps; chain derived from manager line + policy | FR-LVE-003, FR-ATT-007 | +| 3 | No holiday awareness | **Holiday calendars** per branch/year with optional holidays; drives attendance status and leave-day math | FR-LVE-006 | +| 4 | Punch spoofing unaddressed | **Device binding + Play Integrity**, mock-location detection, server speed-of-travel plausibility | FR-ORG-005, FR-ATT-005 | +| 5 | No shared-terminal story | **Kiosk TOTP QR** flow: `KIOSK` role, rotating HMAC-signed 30 s tokens, branch cross-check | FR-ATT-004 | +| 6 | Payroll treated as simple arithmetic | **Statutory rule hooks** (`statutoryCode` + versioned jurisdiction packs), segregation of duties, arrears routing | FR-PAY-005/006, FR-PAY-003 | +| 7 | No tamper-evidence for HR/payroll actions | **Audit immutability**: append-only AuditLog with before/after snapshots on every privileged mutation | FR-PLT-001 | +| 8 | No regional compliance posture | **Data residency** pinning per tenant | FR-PLT-008 | +| 9 | GDPR ignored | **DSR endpoints** (export/erasure with pseudonymization of statutory records) | FR-PLT-007 | +| 10 | Closed system | **Webhooks + open API** (OpenAPI, HMAC-signed events, scoped API keys) | FR-PLT-005, Phase P4 | +| 11 | Password-only auth for enterprises | **SSO (OIDC/SAML) + SCIM** provisioning (future) | FR-ORG-008, Phase P4 | +| 12 | No abuse controls | **Rate limiting** per token/tenant with sync-friendly semantics | FR-PLT-006 | +| 13 | Implicit single-timezone assumption | **Multi-timezone handling**: Company and Branch carry IANA timezones; night shifts and day attribution are shift-timezone-aware; all storage in UTC instants + local date keys | FR-ATT-006, NFR-I18N | +| 14 | No accessibility commitment | **WCAG 2.1 AA** target across Android (TalkBack) and Web Admin | NFR-ACC | +| 15 | English-only assumption | **Localization incl. RTL** (externalized strings, ICU plurals, locale-aware dates/numbers/currency) | NFR-I18N | + +--- + +## 6. Non-functional requirements + +| ID | Category | Requirement | +|---|---|---| +| NFR-AVL-001 | Availability | API availability SLO **99.9%** monthly (measured at the load balancer, excluding client networks); punch write path targets 99.95%. Error budget policy gates risky releases. | +| NFR-LAT-001 | Latency | p95 budgets: `POST /attendance/punches` ≤ 400 ms; `GET /me` ≤ 300 ms; list endpoints ≤ 600 ms; `POST /sync/push` (50-op batch) ≤ 1.5 s; `GET /sync/pull` page ≤ 800 ms. Measured server-side per region. | +| NFR-OFF-001 | Offline | Android supports **≥ 72 h fully offline** for employee flows (punch, leave apply, payslip read of cached data); outbox capacity ≥ 5,000 ops; sync catch-up after 72 h offline completes ≤ 5 min on 4G. | +| NFR-SCL-001 | Scale | 100,000+ employees per tenant; thousands of tenants; ≥ 500 punch writes/s sustained per tenant at shift boundaries; payroll run for 100k employees ≤ 30 min; roster generation 100k × 28 days ≤ 15 min. | +| NFR-SEC-001 | Security | Per master spec §7: Firebase Auth short-lived tokens + custom claims; deny-by-default middleware chain; no direct client Firestore access to server-authoritative collections; TLS 1.2+; at-rest encryption; tokens in EncryptedSharedPreferences/Keystore; no PII in logs; face data stored as embeddings only, raw capture deleted; CMEK option for face templates. | +| NFR-CMP-001 | Compliance | GDPR (DSR, retention), SOC 2 control mapping per `07-security-architecture.md`; payroll-affecting audit retention ≥ 7 years; statutory pack versioning for payroll reproducibility. | +| NFR-ACC-001 | Accessibility | **WCAG 2.1 AA**: full TalkBack/keyboard navigation, ≥ 4.5:1 contrast, touch targets ≥ 48 dp, no information conveyed by color alone; Web Admin passes axe-core CI gate with zero critical violations. | +| NFR-I18N-001 | Localization | All strings externalized; ICU plural/gender support; **RTL layouts** first-class (Arabic/Hebrew/Farsi); locale-aware date/number/currency formatting; per-company currency and per-branch IANA timezone; DST-safe attendance math. | +| NFR-OBS-001 | Observability | Structured logs with trace IDs (no PII), RED metrics per endpoint, alerting on SLO burn rate; every async job (accruals, payroll, roster) emits success/failure metrics and is idempotently re-runnable. | +| NFR-CST-001 | Cost | Per-tenant cost attribution (reads/writes/storage/egress) exportable; Firestore read amplification bounded by projections (AttendanceDay) and BigQuery offload for analytics. | + +## 7. Out of scope (v1 platform) + +- Time-clock **hardware** manufacturing or on-prem biometric terminal integrations (kiosk mode on standard Android tablets covers shared terminals). +- **Tax filing/remittance** to authorities — WorkTrack computes via statutory packs and exports registers; filing is the customer's or partner's responsibility. +- **Benefits administration**, recruitment/ATS, performance management, LMS. +- **iOS app** (API is client-agnostic; iOS is a candidate after Phase P4). +- **Payments execution** (bank integration beyond export files). +- On-prem/self-hosted deployment; WorkTrack is cloud-only on the Firebase/GCP stack. +- Real-time chat/messaging (announcements + notifications only). + +## 8. Assumptions + +1. Every employee-facing user has an Android device (personal or company-issued) or access to a kiosk tablet; Web Admin covers desk personas. +2. Firebase Authentication is the sole identity provider until SSO/SCIM (FR-ORG-008) ships; email/phone uniqueness is per tenant. +3. Tenants accept Google-managed encryption at rest; CMEK is offered for face-template storage only in v1. +4. Statutory packs are developed per launch jurisdiction; a tenant in an unsupported jurisdiction runs payroll with generic components and disclaims statutory accuracy. +5. Firestore, Cloud Functions, Cloud Tasks, Pub/Sub, Cloud Scheduler, BigQuery, and Cloud Storage remain the platform stack (see ADR-001, ADR-008 in `02-system-architecture.md`); multi-cloud portability is a non-goal. +6. Clock integrity: server time is authoritative for validation windows (kiosk TOTP, token expiry); client `punchedAt` is trusted only within configured skew bounds and flagged otherwise. +7. Phase numbering and P0 scope follow `00-master-spec.md` §8 and `09-roadmap.md`; this PRD does not reorder phases. + +## 9. Success metrics + +| Metric | Definition | Target | +|---|---|---| +| Punch success rate | Punches accepted as `serverValidated=true` / total punch attempts (excluding legitimate policy rejections) | ≥ 99% | +| Sync integrity | Outbox ops resolved (DONE or user-actioned FAILED) without support intervention | 100% — silent loss is a sev-1 | +| Regularization resolution time | Median PENDING→decided for RegularizationRequest | ≤ 24 h | +| Leave decision time | Median PENDING→decided for LeaveRequest | ≤ 48 h | +| Payroll accuracy | Payslips requiring post-approval correction per run | ≤ 0.5% | +| Payroll run duration | 100k-employee run, POST → REVIEW-ready | ≤ 30 min | +| Self-service adoption | Monthly active employees / provisioned employees per tenant | ≥ 80% by month 3 | +| Admin efficiency | HR minutes per employee per month spent on attendance corrections | ↓ 50% vs pre-WorkTrack baseline | +| Support load | Tickets per 1,000 employees per month | ≤ 5 after month 2 | + +## 10. Appendix — Requirement traceability (FR → API → entities) + +| FR | Primary endpoints (`/v1`) | Primary entities | +|---|---|---| +| FR-ORG-001 | all (middleware) | Company | +| FR-ORG-002 | `/branches`, `/departments`, `/positions` | Branch, Department, Position | +| FR-ORG-003 | `/employees`, `POST /employees/{id}/deactivate` | Employee | +| FR-ORG-004 | all (middleware) | RoleAssignment | +| FR-ORG-005 | `POST /devices`, `DELETE /devices/{id}` | Device | +| FR-ORG-006 | `GET /me` | Employee, RoleAssignment, Company | +| FR-ORG-007 | `/employees` | Employee (`managerId`) | +| FR-ORG-008 | SSO/SCIM (P4 surface) | Employee, RoleAssignment | +| FR-ORG-009 | role admin (P3 surface) | RoleAssignment | +| FR-ORG-010 | bulk import (P3 surface) | Employee, Department, ShiftAssignment | +| FR-ATT-001/002/003/005/012 | `POST /attendance/punches`, `GET /attendance/punches` | AttendancePunch, Geofence, Device | +| FR-ATT-004 | `POST /attendance/punches` (`method:QR, kioskToken`) | AttendancePunch, Device (`KIOSK`) | +| FR-ATT-006/011 | `GET /attendance/days` | AttendanceDay, Shift, HolidayCalendar | +| FR-ATT-007 | `POST /attendance/regularizations`, `…/{id}/decide` | RegularizationRequest, AttendanceDay | +| FR-ATT-008 | `GET /attendance/days`, `GET /attendance/punches` | AttendanceDay, AttendancePunch | +| FR-ATT-009 | `POST /attendance/punches` (`method:FACE`) | AttendancePunch (`faceScore`) | +| FR-ATT-010 | `GET /attendance/days` | Shift (`overtimePolicyJson`), AttendanceDay | +| FR-SHF-001 | `/shifts` | Shift | +| FR-SHF-002/003 | `GET/PUT /rosters` | ShiftAssignment | +| FR-SHF-004/005 | `POST /shift-swaps`, `…/{id}/decide` | ShiftSwapRequest, ShiftAssignment | +| FR-SHF-006 | roster lock jobs | ShiftAssignment, AuditLog | +| FR-LVE-001 | `GET /leave/types` | LeaveType, LeavePolicy | +| FR-LVE-002/003/009 | `POST /leave/requests`, `…/{id}/decide`, `…/{id}/cancel` | LeaveRequest | +| FR-LVE-004/005 | `GET /leave/balances` | LeaveBalance | +| FR-LVE-006/008 | leave computation, attendance status | HolidayCalendar, Holiday | +| FR-PAY-001 | payroll config (P2 surfaces) | SalaryComponent, SalaryStructure, EmployeeSalary | +| FR-PAY-002/006 | `GET/POST /payroll/runs`, `…/{id}/approve` | PayrollRun | +| FR-PAY-003/008 | run calculation | Payslip, AttendanceDay, LeaveRequest | +| FR-PAY-004 | `GET /payslips`, `GET /payslips/{id}` | Payslip, PayslipLine | +| FR-PAY-005 | run calculation | SalaryComponent (`statutoryCode`) | +| FR-HRO-001 | `GET/POST /announcements` | Announcement | +| FR-HRO-002 | `GET /notifications`, `POST /notifications/{id}/read` | NotificationMessage, Device (`fcmToken`) | +| FR-HRO-003 | document endpoints (P2) | EmployeeDocument | +| FR-HRO-004 | checklist endpoints (P2) | Employee, EmployeeDocument | +| FR-HRO-005 | directory search (P3) | Employee, Position, Department, Branch | +| FR-ANA-001/002/004 | `GET /analytics/kpis` | BigQuery datasets (see `02-system-architecture.md`) | +| FR-ANA-003 | `GET /analytics/insights` | BigQuery feature tables | +| FR-PLT-001 | `GET /audit-logs` | AuditLog | +| FR-PLT-002/003 | `POST /sync/push`, `GET /sync/pull` | OutboxEntry, SyncCursor (client) | +| FR-PLT-004/006 | all (cross-cutting: versioning, envelope, rate limits) | — | +| FR-PLT-005 | webhooks/open API (P4) | — | +| FR-PLT-007 | DSR endpoints (P3) | Employee, EmployeeDocument, AuditLog | +| FR-PLT-008 | tenant provisioning (P3) | Company | diff --git a/docs/02-system-architecture.md b/docs/02-system-architecture.md new file mode 100644 index 0000000..ee91145 --- /dev/null +++ b/docs/02-system-architecture.md @@ -0,0 +1,358 @@ +# WorkTrack — System Architecture + +Version: 1.0 · Status: Approved · Owners: Platform Architecture · Derives from: `00-master-spec.md` + +**Purpose.** This document specifies the system architecture of the WorkTrack platform: C4-style context/container/component views, the responsibilities and contracts of each container, the multi-tenancy and request-lifecycle design, idempotency/pagination/error models, scalability analysis to 100,000 employees per tenant, failure modes and resilience mechanisms, and the Architecture Decision Records that fix the major technology choices. It is the engineering counterpart to `01-product-requirements.md`; security controls are detailed further in `07-security-architecture.md`. + +--- + +## 1. Context view (C4 level 1) + +```mermaid +flowchart TD + EMP["Employee / TEAM_LEAD / BRANCH_MANAGER
(Android app, offline-first)"] + ADM["COMPANY_ADMIN / HR_ADMIN / PAYROLL_ADMIN / AUDITOR
(Web Admin SPA)"] + KSK["KIOSK terminal
(Android tablet, kiosk mode)"] + EXT["Third-party systems
(ERP, IdP, BI) — Phase P4"] + + WT["WorkTrack Platform
(multi-tenant WFM: HRMS + Attendance +
Payroll + Shifts + Leave + Analytics)"] + + FBA["Firebase Authentication
(identity, custom claims)"] + GCP["Google Cloud
(Firestore, Functions, Tasks, Pub/Sub,
Scheduler, Storage, BigQuery, FCM)"] + + EMP -->|"REST v1 (OIDC bearer) + sync"| WT + ADM -->|"REST v1 (same API)"| WT + KSK -->|"rotating TOTP QR display"| WT + EXT -->|"open API + webhooks (P4)"| WT + WT --> FBA + WT --> GCP +``` + +System boundaries: WorkTrack owns everything inside the platform box; Firebase Auth is the identity provider; all compute/storage is GCP-managed. There is no privileged back channel — Android, Web Admin, and third parties consume the same `/v1` REST API (master spec §5). + +## 2. Container view (C4 level 2) + +```mermaid +flowchart TD + subgraph Clients + AND["Android App
Kotlin, Compose, Room, WorkManager
offline-first, outbox + delta sync"] + WEB["Web Admin SPA
React 18 + TS, Firebase Hosting
(Phase P3 build; design 06-web-admin-design.md)"] + end + + subgraph API["API tier — Cloud Functions (Node 20, TypeScript, Express)"] + GW["REST API /v1
middleware: authn → tenant → rbac → handler"] + JOBS["Job handlers
(Tasks/Pub-Sub/Scheduler targets)"] + end + + subgraph Data["Data & async tier"] + FS[("Firestore
system of record
companies/{cid}/…")] + CT["Cloud Tasks
payroll calc queues"] + PS["Pub/Sub
event fan-out"] + SCH["Cloud Scheduler
accruals, roster locks,
day computation"] + GCS[("Cloud Storage
documents, payslip PDFs,
face embeddings")] + BQ[("BigQuery
analytics warehouse")] + FCM["FCM
push notifications"] + end + + AND -->|"HTTPS + Bearer ID token"| GW + WEB -->|"HTTPS + Bearer ID token"| GW + GW --> FS + GW -->|"enqueue"| CT + GW -->|"publish"| PS + CT --> JOBS + PS --> JOBS + SCH --> JOBS + JOBS --> FS + JOBS --> GCS + JOBS --> FCM + FS -->|"export"| BQ + PS -->|"streaming events"| BQ + FCM --> AND +``` + +### 2.1 Container responsibilities + +| Container | Responsibilities | Key constraints | +|---|---|---| +| **Android app** | Offline-first client for EMPLOYEE/TEAM_LEAD/BRANCH_MANAGER personas and kiosk mode. Room is the local source of truth; UI reads only from Room (Flow-based DAOs → repositories → use cases → Compose state). Mutations write Room optimistically and enqueue OutboxEntry rows; `SyncWorker` (WorkManager) drains the outbox FIFO-per-resource and delta-pulls per SyncCursor. Module graph per master spec §6.1 (`app`, `feature:*`, `core:*`). | No direct Firestore SDK access to server-authoritative collections; all mutations via REST. Punches append-only client-side. Tokens in EncryptedSharedPreferences/Keystore. | +| **Web Admin SPA** | React 18 + TypeScript admin console (COMPANY_ADMIN, HR_ADMIN, PAYROLL_ADMIN, BRANCH_MANAGER, AUDITOR). Online-first; consumes the identical `/v1` API; served from Firebase Hosting. | No offline mutation queue; RBAC mirrored client-side for UX only. Implementation is roadmap Phase P3 (design in `06-web-admin-design.md`, referenced by master spec §2 as Phase 4 of the doc set's numbering — canonical delivery phase is P3 per §8). | +| **REST API (Cloud Functions + Express)** | Single versioned HTTP surface `/v1`. Middleware chain (authn → tenant → rbac), request validation, domain services (attendance validation, leave decisioning, sync push/pull), idempotency ledger, audit logging, RFC 7807 errors. | Stateless; min-instances configured on hot functions to bound cold starts on the punch path. Deny-by-default RBAC. | +| **Job handlers** | Same codebase, separate function targets invoked by Cloud Tasks (payroll calculation), Pub/Sub (fan-out consumers: notifications, projections, BigQuery events), Cloud Scheduler (leave accruals, roster generation/locks, AttendanceDay end-of-day sweep). | Every handler idempotent; every queue has a DLQ; job progress persisted in Firestore run documents. | +| **Firestore** | System of record. `companies/{cid}` document + sub-collections per master spec §4.6. Composite indexes on `(employeeId, date)`, `(status, updatedAt)`, `(updatedAt)`. | Security rules deny direct client access to server-authoritative collections (defense in depth behind the API). 1 write/s/document sustained limit drives the sharding design (§6.1). | +| **Cloud Tasks** | Per-tenant payroll calculation queues; controlled concurrency and rate; task = one employee batch. | Named tasks for deduplication; retry with backoff; DLQ-equivalent via max-attempt capture to Firestore. | +| **Pub/Sub** | Event fan-out: `punch.recorded`, `leave.decided`, `payslip.published`, `roster.changed` → notification fan-out, projection recompute, BigQuery streaming, (P4) webhook dispatch. | At-least-once delivery; consumers idempotent; ordering keys per employee where sequence matters. | +| **Cloud Scheduler** | Cron entry points: monthly/yearly accruals, roster lock at T-N days, rotation generation, nightly AttendanceDay sweep per timezone cohort, retention/purge jobs. | Fires a Pub/Sub message or Tasks enqueue; never does the work inline. | +| **Cloud Storage** | Employee documents, payslip PDFs, face embeddings (CMEK option). Access via short-lived signed URLs issued by the API. | No public buckets; per-tenant path prefix `tenants/{cid}/…`; raw face captures deleted post-embedding. | +| **BigQuery** | Analytics warehouse fed by Firestore export + Pub/Sub streaming. Serves `/analytics/kpis`, dashboards, and Phase P4 AI feature pipelines. | Datasets partitioned by date, clustered by `companyId`; analytics never scan Firestore. | +| **FCM** | Push delivery for NotificationMessage fan-out; token lifecycle tracked on Device rows (`fcmToken`). | Push is a hint, not a transport: clients reconcile via `/sync/pull`, so a lost push never loses data. | + +## 3. Component view — API tier (C4 level 3) + +```mermaid +flowchart TD + REQ["HTTPS request"] --> MW1["authn middleware
verify Firebase ID token"] + MW1 --> MW2["tenant middleware
claims {cid,r,b,eid} → TenantContext
URL companyId must match cid"] + MW2 --> MW3["rbac middleware
resource:action check, deny-by-default"] + MW3 --> MW4["validation + idempotency
schema check, Idempotency-Key ledger"] + MW4 --> H["domain handler"] + + subgraph Services["Domain services"] + ATT["AttendanceService
punch validation, AttendanceDay compute"] + LVE["LeaveService
requests, chains, balances"] + SHF["ShiftService
shifts, rosters, swaps"] + PAY["PayrollService
runs, calc orchestration, payslips"] + ORG["OrgService
employees, branches, RBAC admin"] + SYN["SyncService
push (outbox ops), pull (delta cursor)"] + ANA["AnalyticsService
KPI queries (BigQuery)"] + end + + H --> Services + Services --> AUD["AuditLogger
append-only AuditLog"] + Services --> REPO["Firestore repositories
tenant-scoped, ULID IDs"] + Services --> EVT["EventPublisher → Pub/Sub"] + H --> ERR["Error mapper → RFC 7807 problem+json"] +``` + +### 3.1 Component view — Android container + +The Android component structure is the master spec module graph (§6.1) rendered as dependencies: + +```mermaid +graph TD + APP["app"] --> FA["feature:auth"] + APP --> FD["feature:dashboard"] + APP --> FAT["feature:attendance"] + APP --> FL["feature:leave"] + APP --> FP["feature:payslips"] + APP --> FPR["feature:profile"] + + FA & FD & FAT & FL & FP & FPR --> DOM["core:domain
use cases + repository contracts"] + FA & FD & FAT & FL & FP & FPR --> DS["core:designsystem
M3 theme + components"] + + DATA["core:data
repository implementations"] --> DB["core:database
Room, Flow DAOs"] + DATA --> NET["core:network
REST client /v1"] + DATA --> DST["core:datastore
session, preferences"] + DATA --> DOM + SYNC["core:sync
SyncWorker, outbox processor,
WorkManager scheduling"] --> DATA + DOM --> MDL["core:model"] + DB & NET & DST --> MDL + MDL & DOM & DS --> CMN["core:common
Result, dispatchers, time"] +``` + +Responsibilities: `core:database` holds the Room schema mirroring the canonical model (§4 of the master spec) plus client-only OutboxEntry and SyncCursor tables; `core:network` is the typed `/v1` client (auth interceptor, problem+json decoding, idempotency header injection); `core:sync` owns the outbox drain (FIFO per resource) and delta pull; `core:domain` exposes use cases so `feature:*` modules never see data-layer types. Build wiring comes from the `build-logic/` convention plugins named in master spec §6.1. + +### 3.2 Key flows + +**Punch validation (server-side), `POST /attendance/punches`:** + +```mermaid +flowchart TD + A["Punch request
(GPS | QR | FACE | KIOSK | MANUAL)"] --> B{"Device bound +
Play Integrity verdict OK?"} + B -- no --> R1["Persist punch, serverValidated=false
invalidReason=integrity · 422 problem"] + B -- yes --> C{"method?"} + C -- GPS --> D{"inside geofence?
+ speed-of-travel plausible?
+ isMock false?"} + C -- QR --> E{"kioskToken HMAC valid,
within 30s window,
kiosk branch = employee branch?"} + C -- FACE --> F{"faceScore ≥ tenant threshold?"} + D & E & F -- fail --> R2["Persist append-only with
insideFence/invalidReason set
→ regularization path"] + D & E & F -- pass --> G["Persist punch
serverValidated=true"] + G --> H["Publish punch.recorded → Pub/Sub"] + H --> I["AttendanceDay recompute
(ordering key = employeeId)"] + I --> J["KPI event → BigQuery stream"] +``` + +**Sync cycle (client outbox + delta pull):** + +```mermaid +flowchart TD + M["Local mutation"] --> T["Room txn: optimistic row
(syncStatus=PENDING) + OutboxEntry
(ULID idempotencyKey)"] + T --> W["SyncWorker
(network-constrained, unique work,
exponential backoff)"] + W --> P["POST /sync/push
batched ops, FIFO per resource"] + P --> S{"per-item result"} + S -- ok --> OK["Room: syncStatus=SYNCED
server fields win · outbox DONE"] + S -- "4xx problem" --> KO["Outbox FAILED +
actionable notification
(never silent loss)"] + S -- "5xx / 429" --> RB["Keep PENDING
retry with backoff"] + OK --> PU["GET /sync/pull?types&cursor
per-resource watermark"] + PU --> AP["Apply deltas + tombstones
advance SyncCursor"] +``` + +--- + +## 4. Multi-tenancy design + +1. **Storage isolation** — every aggregate lives under `companies/{companyId}/…` sub-collections (master spec §4.6). There are no cross-tenant collections except platform-internal operator data. Collection-group queries are used only by `SUPER_ADMIN` tooling and are permission-fenced. +2. **Identity binding** — Firebase Auth custom claims carry `{ cid: companyId, r: [roleCodes], b: [branchIds], eid: employeeId }`. Claims are set server-side at employee provisioning/role change; a claim change forces token refresh (≤ 60 min natural expiry; deactivation additionally revokes refresh tokens). +3. **Request binding** — every route resolves the tenant from the **verified ID token, never from the URL alone**; if a URL carries `companyId` it must equal `cid` or the request fails with `403` (`tenant-mismatch` problem type). Repositories accept a `TenantContext` and prefix every Firestore path with it — a handler cannot physically address another tenant's collection. +4. **Scope enforcement** — RBAC scoping (COMPANY/BRANCH/DEPARTMENT via RoleAssignment) is applied as query constraints (e.g. a `BRANCH_MANAGER` roster query is forced to `branchId ∈ claims.b`), not post-filtering. +5. **Blast-radius controls** — per-tenant Cloud Tasks queues and per-tenant rate limits prevent one tenant's payroll run or sync storm from starving others; per-tenant BigQuery partitioning bounds analytics cost attribution (§6.4). + +## 5. Cross-cutting API design + +### 5.1 Request lifecycle + +Middleware order is fixed: `authn → tenant → rbac → validation/idempotency → handler → audit/event → response`. Failures short-circuit with RFC 7807 bodies. Every request carries a generated `requestId` (returned as `X-Request-Id`, logged, and attached to problem responses as `instance`). + +### 5.2 Idempotency design + +- `Idempotency-Key` header honored on **all POSTs** (master spec §5). Clients use ULIDs; the Android outbox uses the OutboxEntry `idempotencyKey`. +- Ledger: `companies/{cid}/idempotency/{key}` document storing `{requestHash, status, responseSnapshot, createdAt, expiresAt}`. TTL 24 h (sync/punch) to 30 days (payroll run creation). +- Semantics: first request executes inside a transaction that also creates the ledger entry; replay with same key + same `requestHash` returns the stored response with `Idempotency-Replayed: true`; same key + different hash → `409 idempotency-key-reuse`; concurrent duplicate (`status=IN_PROGRESS`) → `409` with `Retry-After`. +- Append-only punches get a second guard: the punch ID itself is the client ULID, so even a ledger miss cannot double-insert. + +### 5.3 Pagination / cursor design + +- All list endpoints: `?cursor&limit` (default 25, max 100 for interactive; `/sync/pull` max 500). Envelope: `{ "data": [...], "meta": { "cursor": "..." } }`; absent `meta.cursor` = last page. +- Cursor = opaque base64url token encoding `{orderField(s), lastValues, direction, filterHash}` + HMAC. Tampering or reuse across a changed filter set → `400 invalid-cursor`. +- Ordering is always over an indexed, unique-suffixed key (e.g. `(date, id)` or `(updatedAt, id)` using ULID tiebreaker) so pagination is stable under concurrent writes. +- `/sync/pull` cursors are per resource type (client SyncCursor rows) and are watermark cursors over `(updatedAt, id)`; deletes are delivered as tombstones (`deletedAt` set) so clients can converge. + +### 5.4 Error model (RFC 7807) + +`Content-Type: application/problem+json`. Problem `type` URIs are stable API contract: `https://api.worktrack.app/problems/`. + +```json +{ + "type": "https://api.worktrack.app/problems/outside-geofence", + "title": "Punch outside geofence", + "status": 422, + "detail": "Location is 412 m from branch fence 'HQ-North' (radius 150 m).", + "instance": "/v1/attendance/punches/01J8ZQ…", + "requestId": "req_01J8ZQ…", + "errors": [{ "field": "lat", "reason": "outside_fence" }] +} +``` + +Canonical problem catalog (excerpt): `validation-failed` (400), `invalid-cursor` (400), `unauthenticated` (401), `permission-denied` / `tenant-mismatch` (403), `not-found` (404), `conflict` / `idempotency-key-reuse` / `version-conflict` (409), `outside-geofence` / `integrity-verdict-failed` / `insufficient-balance` / `kiosk-token-invalid` (422), `rate-limited` (429, with `Retry-After`), `internal` (500), `dependency-unavailable` (503). The Android sync layer maps 4xx problems to actionable user notifications and 5xx/429 to retry-with-backoff. + +--- + +## 6. Scalability analysis + +### 6.1 Firestore write sharding for hot aggregates + +Hot spots and their treatment: + +| Hot aggregate | Load pattern | Design | +|---|---|---| +| `punches` | Burst at shift boundaries (thousands of writes/min/tenant) | Naturally sharded: one document per punch, ULID doc IDs (near-monotonic but written across many employees → no single hot document; collection index fan-in is the limit, monitored). | +| `attendanceDays` | One doc per employee/date, recomputed on punch/regularization | Document key `{employeeId}_{date}` — writes distribute across employees; per-document rate is ≤ a few writes/day. Recompute is event-driven (Pub/Sub, ordering key = employeeId) + nightly sweep; `version` field makes recompute last-writer-safe. | +| Company-level counters (present count, live KPI tiles) | Every punch would touch one doc → exceeds 1 write/s/doc | **Sharded counters**: `attendanceDayAgg/{date}/shards/{0..N}` (N sized by branch headcount, default 20); readers sum shards; N is resizable online. At ≥ 5k employees/branch these counters are dropped entirely in favor of BigQuery-served KPIs. | +| `payrollRuns` progress | 100k task completions updating one run doc | Tasks update per-batch progress docs `payrollRuns/{id}/batches/{n}`; a Pub/Sub-driven aggregator folds batch states into the run doc at ≤ 1 write/s. | +| Idempotency ledger | Bursty on sync push | Keyed by client ULID → uniformly distributed; TTL-expired via scheduled purge. | + +### 6.2 Fan-out strategies + +- **Notification fan-out** (announcement to 100k employees): the API writes the Announcement once and publishes to Pub/Sub; a consumer expands the audience in pages of 500, writing NotificationMessage docs via BulkWriter and batching FCM sends (500/multicast). No request-path fan-out. +- **Projection fan-out** (punch → AttendanceDay → KPI event): chained through Pub/Sub with per-employee ordering keys; each stage idempotent (recompute-from-source, not increment). +- **Roster fan-out**: rotation generation emits per-branch jobs; each job writes ShiftAssignments in 500-doc batches. + +### 6.3 Scaling to 100k employees per tenant (explicit design) + +| Concern | Naive approach (rejected) | 100k design | +|---|---|---| +| Roster generation (100k × 28 days ≈ 2.8M ShiftAssignments) | Single function invocation loops all employees — exceeds function timeout, memory | Cloud Scheduler → orchestrator enqueues **batched Cloud Tasks jobs** (1 task = 1 branch or 1k-employee slice); each task writes ≤ 500-doc batches with progress checkpoints; resumable at slice granularity; target ≤ 15 min end-to-end | +| Payroll run (100k payslips) | Synchronous calculation in the API request | `POST /payroll/runs` returns `202`-style DRAFT→CALCULATING immediately; orchestrator shards employees into **Cloud Tasks queue** batches (250/task, per-tenant queue with capped dispatch rate); per-batch results in sub-docs; failed employees quarantined to an exceptions list without failing the run; target ≤ 30 min | +| Analytics/KPIs | Firestore collection scans + in-memory aggregation | **BigQuery instead of Firestore aggregation**: Firestore export + Pub/Sub streaming keep BQ ≤ 24 h fresh (streamed events near-real-time); `/analytics/kpis` queries partitioned/clustered BQ tables; Firestore serves only small precomputed counter tiles at low headcounts | +| Attendance day sweep | One nightly job for all tenants | Timezone-cohort scheduling: Scheduler fires per timezone offset; per-tenant per-branch tasks; only employees with activity or expected shifts are touched (query on `(status, updatedAt)` index) | +| Sync pull after long offline | Unbounded delta | Watermark cursor + 500-doc pages + per-type prioritization (punches/assignments first); server caps a single pull session and the client resumes — no timeout cliffs | +| Directory search | Firestore prefix queries at 100k | Search index in BigQuery (P3) or dedicated index; Firestore remains source of record | + +### 6.4 Per-tenant isolation & cost controls + +- Per-tenant Cloud Tasks queues (payroll) and per-tenant rate limits (API) bound noisy-neighbor impact. +- Cost attribution: Pub/Sub event stream aggregates per-tenant document read/write counts into a daily BigQuery cost table (NFR-CST-001); plan enforcement (Company `plan`) throttles or gates expensive features (analytics ranges, export frequency). +- Firestore read amplification is bounded by design: clients read projections (AttendanceDay) not raw punches; list endpoints cap ranges (≤ 92 days); dashboards read BigQuery. + +## 7. Failure modes & resilience + +| Failure | Detection | Response | Degradation | +|---|---|---|---| +| API unavailable / network loss (client) | OkHttp failures, sync errors | Outbox retains ops; WorkManager retries with exponential backoff + jitter (network-constrained, unique work) | Full offline operation from Room ≥ 72 h; UI shows sync state, never blocks punch capture | +| Firestore unavailable | Health checks, error rates | Functions return `503 dependency-unavailable` with `Retry-After`; clients back off | Reads may be served stale from client cache; no writes accepted (no write-behind on server) | +| Cloud Tasks handler crash | Task retry with backoff (max 10 attempts) | Idempotent handlers re-run safely; after max attempts, task payload captured to `deadLetters` collection + alert | Payroll batch marked failed-quarantined; run continues; operator re-drives from DLQ | +| Pub/Sub consumer failure | Redelivery, DLQ topic after 5 attempts | DLQ subscription + replayer tool; consumers idempotent so replay is safe | Projections lag; source of record unaffected; KPI staleness visible via `computedAt` | +| Duplicate delivery (Tasks/PubSub at-least-once) | — | Idempotency by natural keys (`{employeeId}_{date}`, punch ULIDs, run+batch IDs) | None — by construction | +| Kiosk offline | Kiosk detects staleness | TOTP QRs are generated locally from a provisioned secret — kiosk keeps issuing valid codes offline; employee app queues the punch | Server validates on sync within skew window; branch mismatch still enforced server-side | +| FCM push loss | — | Push is advisory; `/sync/pull` on app foreground reconciles | Delayed notification, no data loss | +| Clock skew (client) | Server compares `punchedAt` vs receipt time | Outside skew bound → punch stored with `invalidReason=clock-skew`, flagged for regularization | Employee informed; no silent rejection | +| Sync conflict (server rejects op) | 4xx problem on `/sync/push` item | Per-item results in batch response; client marks OutboxEntry FAILED and raises an actionable notification (master spec §6.3.6) | Never silent data loss; user can amend and resubmit | +| Regional outage | Cloud Monitoring | Multi-region Firestore (nam5/eur3-class) rides zone loss; regional function outage → status page, error budget consumed | Offline-first clients absorb API downtime for field workflows | + +Retry policy summary: client outbox — exponential backoff with jitter, base 30 s, cap 1 h, retained until explicit failure classification (4xx = terminal → user action; 5xx/429 = retry). Server-to-server — Tasks/PubSub native retries, handlers idempotent, DLQ after bounded attempts, replay tooling + alerting on DLQ depth > 0. + +## 8. Operational architecture + +### 8.1 Environments & deployment + +| Environment | Purpose | Data | Notes | +|---|---|---|---| +| `dev` | Per-engineer iteration | Synthetic seed tenants | Firebase Emulator Suite (Auth, Firestore, Functions) for local work; shared dev project for integration | +| `staging` | Pre-release validation | Synthetic incl. the 100k-employee load tenant | Mirrors prod config incl. Firestore indexes, Scheduler jobs, queues; release-gate suites run here | +| `prod` | Customer traffic | Tenant data, region-pinned | Progressive rollout; Android via Play staged rollout, functions via traffic-safe deploy | + +CI/CD: trunk-based; every merge runs unit + rules-emulator + API contract tests; staging deploy on merge; prod deploy is a tagged release with automated canary checks against SLO burn (rollback = redeploy previous tag; Firestore schema changes are additive-only, so rollback never needs data migration). Android release train is fortnightly; server API remains backward-compatible with the two previous app versions (additive `/v1` evolution per master spec §3.4). + +### 8.2 Observability + +- **Correlation** — `X-Request-Id` generated at ingress, propagated into logs, Pub/Sub message attributes, Cloud Tasks payloads, and RFC 7807 `requestId`; a payroll run's `runId` links every batch log. +- **Metrics** — RED per endpoint (rate, errors, duration histograms) tagged by tenant plan tier (not tenant ID, to bound cardinality); queue depth, DLQ depth, job durations, sync push batch outcomes, punch validation outcomes by `invalidReason`. +- **SLO monitoring** — burn-rate alerts on NFR-AVL/NFR-LAT budgets (`01-product-requirements.md` §6); paging on fast burn, ticketing on slow burn. +- **Logs** — structured JSON, PII-free by lint-enforced logging helpers; audit-relevant events go to AuditLog (the product feature), operational logs to Cloud Logging (30-day retention). +- **Client telemetry** — crash reporting plus sync-health beacons (outbox depth, oldest PENDING age); a fleet-wide rise in oldest-PENDING age is the leading indicator of a sync regression. + +### 8.3 Data lifecycle & retention + +| Data class | Store | Retention | Disposal | +|---|---|---|---| +| AttendancePunch, AttendanceDay | Firestore (+ BigQuery) | 7 years (payroll-affecting) | Archive to Storage export, then purge job | +| AuditLog | Firestore (+ BigQuery) | ≥ 7 years, immutable | Legal-hold aware purge | +| Payslip, PayrollRun | Firestore + PDF in Storage | ≥ 7 years | Never purged while tenant active without legal review | +| Face embeddings | Cloud Storage (CMEK option) | Employment + 30 days | Hard delete on exit/opt-out; raw captures deleted post-embedding (never retained) | +| EmployeeDocument | Cloud Storage | Per-kind policy, tenant-configurable | Signed-URL access only; delete on DSR where lawful | +| NotificationMessage | Firestore | 180 days | TTL purge | +| Idempotency ledger | Firestore | 24 h – 30 days by endpoint class | TTL purge | +| Operational logs | Cloud Logging | 30 days | Automatic | +| DSR erasure | cross-cutting | — | PII pseudonymized in place; financial/statutory records retain integrity (FR-PLT-007) | + +--- + +## 9. Appendix — Architecture Decision Records + +### ADR-001 — Firestore vs Cloud SQL as system of record +- **Context.** The system of record must serve thousands of tenants, offline-syncing mobile clients, per-tenant isolation, and spiky write bursts at shift boundaries, with a small platform team and no DBA capacity. +- **Decision.** Firestore, laid out as `companies/{cid}` sub-collections; relational integrity enforced in the service layer; analytics offloaded to BigQuery. +- **Consequences.** (+) Zero-ops horizontal scale, per-document ACLs as defense-in-depth, natural fit for delta sync (`updatedAt` watermarks), multi-region durability. (−) No joins/aggregates — requires projections (AttendanceDay), sharded counters, and BigQuery for analytics; 1 write/s/doc constraint shapes design (§6.1); cross-entity invariants (leave balances) need transactions and `version` fields. Revisit if a workload emerges that requires multi-entity transactions beyond Firestore's limits. + +### ADR-002 — ULID identifiers +- **Context.** Offline clients must create entities (punches, leave requests) without a server round-trip; IDs must be globally unique, sortable for cursors, and index-friendly. +- **Decision.** ULIDs everywhere (client- and server-generated), doubling as idempotency keys for created resources. +- **Consequences.** (+) Offline generation, lexicographic time-ordering enables `(field, id)` cursor tiebreaks, no coordination. (−) IDs embed creation time (minor information leak — acceptable, IDs are never exposed unauthenticated); near-monotonic doc IDs could hot-spot a single-collection index at extreme write rates — mitigated because writes spread across per-tenant collections and many employees. + +### ADR-003 — Server-authoritative writes for money/compliance paths +- **Context.** Attendance validity, leave balances, and payroll affect pay and legal compliance; offline clients can hold stale state or be tampered with. +- **Decision.** Clients propose, the server decides (master spec §3.1): punch validity, AttendanceDay computation, balance movements, and payroll math execute exclusively server-side; Firestore rules deny direct client writes to these collections. +- **Consequences.** (+) Single point of truth and audit; tamper resistance; recompute is always possible from append-only sources. (−) Offline UX shows provisional state (`syncStatus=PENDING`) that may later be rejected — mitigated by actionable rejection notifications and the regularization path; server must be sized for all computation. + +### ADR-004 — Client outbox pattern with idempotency keys +- **Context.** Offline-first mutations need exactly-once effect over an at-least-once network, ordered per resource, surviving process death. +- **Decision.** Every local mutation enqueues a durable OutboxEntry (ULID `idempotencyKey`, FIFO per resource) in Room; `SyncWorker` drains via `POST /sync/push` batches; the server's idempotency ledger (§5.2) deduplicates. +- **Consequences.** (+) Exactly-once effect, crash-safe, testable queue semantics, uniform mutation path. (−) Two write paths on client (optimistic row + outbox) must stay consistent — enforced by writing both in one Room transaction; queue-head failures block a resource's queue — mitigated by terminal/retryable error classification (§7). + +### ADR-005 — REST over gRPC +- **Context.** Two first-party clients (Android, browser SPA) plus future third-party integrators; Cloud Functions HTTP triggers; team debugging ergonomics. +- **Decision.** Versioned JSON REST (`/v1`) with RFC 7807 errors, cursor pagination, and idempotency headers; no gRPC surface. +- **Consequences.** (+) Browser-native, curl-debuggable, gateway/CDN-friendly, trivially consumable by partners (OpenAPI in P4); Cloud Functions HTTP fit. (−) No streaming (acceptable: sync is pull-based; push hints via FCM), no generated strong contracts — mitigated with OpenAPI-driven codegen for the Retrofit and web clients; JSON overhead acceptable at our payload sizes. + +### ADR-006 — Cloud Functions vs Cloud Run for the API tier +- **Context.** Choice of serverless compute for Express: Functions (per-function deploy, scale-to-zero) vs Cloud Run (container, concurrency > 1, fewer cold-start pathologies). +- **Decision.** Cloud Functions (Node 20) for P0–P2, with the Express app structured as a standard container-ready codebase; min-instances on the punch/sync functions to bound cold starts. +- **Consequences.** (+) Lowest ops burden, native Firebase integration (auth context, deploy tooling), per-function scaling and IAM. (−) Cold starts and per-instance concurrency=1 cost more at high QPS; migration path to Cloud Run is explicitly preserved (no Functions-only APIs in handler code; Express app is host-agnostic). Trigger to migrate: sustained QPS where Run's concurrency materially cuts cost, or p95 latency breaches from cold starts. + +### ADR-007 — Append-only events for punches and audit logs +- **Context.** Attendance punches and audit trails are legally sensitive; offline sync of mutable records requires conflict resolution. +- **Decision.** AttendancePunch and AuditLog are append-only and immutable (master spec §4.2, §4.5); corrections are new facts (RegularizationRequest) not edits; derived state (AttendanceDay) is recomputed, never hand-edited. +- **Consequences.** (+) No sync conflicts by construction, tamper-evidence, deterministic recomputation, simple client contract (no update/delete ops). (−) Storage grows monotonically — bounded by retention/archival policies (BigQuery + Storage export before purge); "wrong" punches remain visible — presented with `invalidReason` and superseding regularizations. + +### ADR-008 — BigQuery for analytics instead of Firestore aggregation +- **Context.** KPIs, trends, and AI features over 100k-employee tenants; Firestore cannot aggregate and per-read costs make scans prohibitive. +- **Decision.** Firestore → BigQuery export plus Pub/Sub streaming events populate a tenant-partitioned warehouse; `/analytics/kpis` and `/analytics/insights` read BigQuery only; Firestore keeps at most small precomputed counter tiles for low-headcount real-time widgets. +- **Consequences.** (+) SQL analytics at scale, ML feature pipelines (Phase P4) get a native home, cost per query is bounded by partitioning/clustering. (−) Freshness ≤ 24 h for export-fed tables (streamed events narrow this); a second data platform to operate — accepted as the price of correct tool separation: Firestore for transactions, BigQuery for analysis. diff --git a/docs/03-database-design.md b/docs/03-database-design.md new file mode 100644 index 0000000..03e3876 --- /dev/null +++ b/docs/03-database-design.md @@ -0,0 +1,735 @@ +# WorkTrack — Database Design + +Version: 1.0 · Status: Approved · Owners: Platform Architecture · Derives from: `00-master-spec.md` (§4, §6.3) + +**Purpose.** This document specifies the persistence layer of WorkTrack end-to-end: the normalized logical model (3NF), its projection onto the two physical stores — Firestore (server system of record) and Room (Android offline store) — the full data dictionary for every entity in master spec §4, the Firestore collection/index/sharding plan for 100k-employee tenants, the on-device schema and retention windows, and the data lifecycle including BigQuery archival, soft-delete semantics, and GDPR erasure via crypto-shredding. It is the binding contract for `core:database` (Room), the Cloud Functions data access layer, and the Firestore security-rules model. + +--- + +## 1. Modeling approach + +### 1.1 Logical model: 3NF + +The canonical model in master spec §4 is maintained in third normal form: + +- **1NF** — all attributes atomic; repeating groups are extracted (e.g. `PayslipLine` rows instead of an amounts array; `Holiday` rows instead of a date list on `HolidayCalendar`). +- **2NF** — no partial dependencies on composite keys; every entity has a single surrogate ULID primary key, and natural keys (`Employee.employeeCode`, `Shift.code`, `LeaveType.code`) are enforced as unique constraints, not identifiers. +- **3NF** — no transitive dependencies: employee org placement lives only on `Employee` (`branchId`, `departmentId`, `positionId`); pay composition lives only on `SalaryComponent`/`SalaryStructure`; shift timing lives only on `Shift`. + +Two classes of entity deliberately relax pure normalization, exactly as §4 declares: + +| Class | Entities | Rationale | +|---|---|---| +| Append-only event logs | `AttendancePunch`, `AuditLog` | Immutable facts; no updates ⇒ no update anomalies, no sync conflicts | +| Server-computed projections | `AttendanceDay`, `LeaveBalance`, `PayrollRun.totalsJson` | Derived aggregates materialized for read performance; recomputable from events; guarded by `version` for optimistic concurrency | + +### 1.2 Mapping to the two physical stores + +| Concern | Firestore (server) | Room (Android) | +|---|---|---| +| Unit | Document in a per-tenant sub-collection (`companies/{cid}/…`, §4.6) | Row in a SQLite table, one table per entity | +| Primary key | Document ID = entity `id` (ULID, §3 below) | `id TEXT PRIMARY KEY` (same ULID) | +| Foreign keys | By-ID reference fields; integrity enforced in the API layer (Firestore has no FK constraints) | Declared `FOREIGN KEY` with `ON DELETE NO ACTION`; indices on every FK column | +| Enums | Uppercase string codes as written in §4 | `TEXT` + `@TypeConverter` to Kotlin enums | +| `*Json` fields | Nested map on the document | `TEXT` column holding canonical JSON (kotlinx.serialization) | +| Timestamps | Firestore `Timestamp` | `INTEGER` epoch millis UTC | +| Dates | `"yyyy-MM-dd"` string (timezone-independent business date) | `TEXT` ISO date | +| Tenancy | Structural (sub-collection path) + `companyId` field duplicated on the doc for collection-group queries and BigQuery export | `companyId` column; single-tenant device, kept for integrity checks | +| Concurrency | `updateTime` preconditions + `version` field on projections | `syncStatus` column; server fields win on reconcile (§6.3 of master spec) | + +The same ULID is the identifier in both stores and in the REST API — there is no ID translation layer. Clients generate ULIDs offline; the server accepts them for client-originated aggregates (punches, leave requests, regularizations, swap requests) and generates them for server-originated ones (attendance days, payslips, payroll runs). + +--- + +## 2. ER diagrams + +Entities shown with key/discriminator fields; the full field list is in the data dictionary (§4 of this document). Entities suffixed `_REF` are cross-domain references owned by the Org & Identity diagram. `?` in a comment means nullable. + +### 2.1 Org & Identity + +```mermaid +erDiagram + COMPANY ||--o{ BRANCH : "operates" + COMPANY ||--o{ DEPARTMENT : "defines" + COMPANY ||--o{ POSITION : "defines" + COMPANY ||--o{ EMPLOYEE : "employs" + BRANCH |o--o{ DEPARTMENT : "hosts (optional)" + DEPARTMENT |o--o{ DEPARTMENT : "parent of" + DEPARTMENT |o--o{ POSITION : "groups (optional)" + BRANCH ||--o{ EMPLOYEE : "home branch" + DEPARTMENT ||--o{ EMPLOYEE : "assigned" + POSITION ||--o{ EMPLOYEE : "holds" + EMPLOYEE |o--o{ EMPLOYEE : "manages" + EMPLOYEE ||--o{ ROLE_ASSIGNMENT : "granted" + EMPLOYEE ||--o{ DEVICE : "binds" + + COMPANY { + ulid id PK + string name + string timezone + string currency + } + BRANCH { + ulid id PK + ulid companyId FK + string code + double lat + double lng + int radiusM + } + DEPARTMENT { + ulid id PK + ulid companyId FK + ulid branchId FK "?" + ulid parentDepartmentId FK "?" + string code + } + POSITION { + ulid id PK + ulid companyId FK + ulid departmentId FK "?" + string code + int level + } + EMPLOYEE { + ulid id PK + ulid companyId FK + string employeeCode "unique per company" + ulid branchId FK + ulid departmentId FK + ulid positionId FK + ulid managerId FK "?" + enum status "ACTIVE|ON_LEAVE|SUSPENDED|EXITED" + string authUid "Firebase Auth UID" + } + ROLE_ASSIGNMENT { + ulid id PK + ulid companyId FK + ulid employeeId FK + string roleCode + enum scopeType "COMPANY|BRANCH|DEPARTMENT" + ulid scopeId "?" + } + DEVICE { + ulid id PK + ulid companyId FK + ulid employeeId FK + timestamp boundAt + timestamp revokedAt "?" + } +``` + +### 2.2 Attendance & Scheduling + +```mermaid +erDiagram + BRANCH_REF ||--o{ GEOFENCE : "covers" + EMPLOYEE_REF ||--o{ SHIFT_ASSIGNMENT : "scheduled" + SHIFT ||--o{ SHIFT_ASSIGNMENT : "instantiated as" + SHIFT_ASSIGNMENT ||--o{ SHIFT_SWAP_REQUEST : "subject of" + EMPLOYEE_REF ||--o{ SHIFT_SWAP_REQUEST : "requests" + EMPLOYEE_REF ||--o{ ATTENDANCE_PUNCH : "records" + GEOFENCE |o--o{ ATTENDANCE_PUNCH : "matched by (optional)" + DEVICE_REF ||--o{ ATTENDANCE_PUNCH : "originates" + EMPLOYEE_REF ||--o{ ATTENDANCE_DAY : "summarized per date" + SHIFT |o--o{ ATTENDANCE_DAY : "evaluated against" + EMPLOYEE_REF ||--o{ REGULARIZATION_REQUEST : "files" + + GEOFENCE { + ulid id PK + ulid companyId FK + ulid branchId FK + double lat + double lng + int radiusM + } + SHIFT { + ulid id PK + ulid companyId FK + string code + string startTime + string endTime + boolean isNight + } + SHIFT_ASSIGNMENT { + ulid id PK + ulid companyId FK + ulid employeeId FK + ulid shiftId FK + date date + ulid branchId FK + enum source "ROSTER|ROTATION|MANUAL|SWAP" + string status + } + SHIFT_SWAP_REQUEST { + ulid id PK + ulid companyId FK + ulid requesterId FK + ulid targetEmployeeId FK "?" + ulid assignmentId FK + string status + } + ATTENDANCE_PUNCH { + ulid id PK "append-only" + ulid companyId FK + ulid employeeId FK + timestamp punchedAt + enum type "IN|OUT" + enum method "GPS|QR|FACE|MANUAL|KIOSK" + ulid geofenceId FK "?" + boolean insideFence + boolean serverValidated + } + ATTENDANCE_DAY { + ulid id PK "server-computed" + ulid companyId FK + ulid employeeId FK + date date "unique with employeeId" + ulid shiftId FK "?" + enum status "PRESENT|ABSENT|HALF_DAY|LEAVE|HOLIDAY|WEEK_OFF|PENDING" + int version "optimistic lock" + } + REGULARIZATION_REQUEST { + ulid id PK + ulid companyId FK + ulid employeeId FK + date date + enum status "PENDING|APPROVED|REJECTED|CANCELLED" + ulid decidedBy "?" + } +``` + +### 2.3 Leave + +```mermaid +erDiagram + LEAVE_TYPE ||--o{ LEAVE_POLICY : "governed by" + LEAVE_TYPE ||--o{ LEAVE_BALANCE : "tracked per employee-year" + LEAVE_TYPE ||--o{ LEAVE_REQUEST : "requested as" + EMPLOYEE_REF ||--o{ LEAVE_BALANCE : "owns" + EMPLOYEE_REF ||--o{ LEAVE_REQUEST : "files" + EMPLOYEE_REF |o--o{ LEAVE_REQUEST : "current approver of" + HOLIDAY_CALENDAR ||--o{ HOLIDAY : "contains" + + LEAVE_TYPE { + ulid id PK + ulid companyId FK + string code + boolean isPaid + } + LEAVE_POLICY { + ulid id PK + ulid companyId FK + ulid leaveTypeId FK + enum accrualRule "NONE|MONTHLY|YEARLY|ANNIVERSARY" + double accrualDays + json appliesToJson + } + LEAVE_BALANCE { + ulid id PK "server-computed" + ulid companyId FK + ulid employeeId FK + ulid leaveTypeId FK + int periodYear "unique with employeeId+leaveTypeId" + double pendingDays + int version "optimistic lock" + } + LEAVE_REQUEST { + ulid id PK + ulid companyId FK + ulid employeeId FK + ulid leaveTypeId FK + date startDate + date endDate + double days + enum status "DRAFT|PENDING|APPROVED|REJECTED|CANCELLED" + json approvalChainJson + ulid currentApproverId FK "?" + } + HOLIDAY_CALENDAR { + ulid id PK + ulid companyId FK + int year + json branchIdsJson + } + HOLIDAY { + ulid id PK + ulid calendarId FK + date date + boolean isOptional + } +``` + +### 2.4 Payroll & Platform + +```mermaid +erDiagram + SALARY_COMPONENT }o--o{ SALARY_STRUCTURE : "composed via componentIdsJson" + SALARY_STRUCTURE ||--o{ EMPLOYEE_SALARY : "applied as" + EMPLOYEE_REF ||--o{ EMPLOYEE_SALARY : "compensated by (effective-dated)" + PAYROLL_RUN ||--o{ PAYSLIP : "produces" + EMPLOYEE_REF ||--o{ PAYSLIP : "paid via" + PAYSLIP ||--|{ PAYSLIP_LINE : "itemized by" + EMPLOYEE_REF ||--o{ EMPLOYEE_DOCUMENT : "owns" + EMPLOYEE_REF ||--o{ NOTIFICATION_MESSAGE : "receives" + EMPLOYEE_REF ||--o{ AUDIT_LOG : "acts in" + COMPANY_REF ||--o{ ANNOUNCEMENT : "publishes" + + SALARY_COMPONENT { + ulid id PK + ulid companyId FK + string code + enum type "EARNING|DEDUCTION|EMPLOYER_COST" + enum calc "FIXED|PERCENT_OF_BASIC|PERCENT_OF_GROSS|FORMULA" + } + SALARY_STRUCTURE { + ulid id PK + ulid companyId FK + json componentIdsJson + } + EMPLOYEE_SALARY { + ulid id PK + ulid companyId FK + ulid employeeId FK + ulid structureId FK + double basicAmount + date effectiveFrom + date effectiveTo "?" + } + PAYROLL_RUN { + ulid id PK + ulid companyId FK + int periodYear + int periodMonth + enum status "DRAFT|CALCULATING|REVIEW|APPROVED|PAID|CLOSED" + ulid startedBy FK + timestamp lockedAt "?" + } + PAYSLIP { + ulid id PK + ulid companyId FK + ulid runId FK + ulid employeeId FK + double gross + double net + string status + } + PAYSLIP_LINE { + ulid id PK + ulid payslipId FK + string componentCode "snapshot" + string componentName "snapshot" + double amount + } + ANNOUNCEMENT { + ulid id PK + ulid companyId FK + json audienceJson + timestamp publishAt + } + EMPLOYEE_DOCUMENT { + ulid id PK + ulid companyId FK + ulid employeeId FK + string kind + string storagePath + } + AUDIT_LOG { + ulid id PK "append-only immutable" + ulid companyId FK + ulid actorId FK + string action + string resourceType + ulid resourceId + timestamp at + } + NOTIFICATION_MESSAGE { + ulid id PK + ulid companyId FK + ulid employeeId FK + string kind + timestamp sentAt + } +``` + +--- + +## 3. Identifier strategy + +- **ULIDs everywhere** (26-char Crockford base32). Sortable by creation time, generatable offline on Android with zero coordination, collision-safe (80 bits of randomness). The ULID is simultaneously the Room PK, the Firestore document ID, and the REST resource ID. +- Client-originated entities (punches, leave requests, regularizations, swap requests, devices, outbox ops) mint their ULID on-device; the server persists it verbatim, which makes retries naturally idempotent. +- Natural business keys (`employeeCode`, `Shift.code`, `LeaveType.code`, `SalaryComponent.code`, `Branch.code`) are unique **within a company** and enforced by API-layer transactional lookups (Firestore has no unique constraints); Room mirrors them with `UNIQUE` indices. +- Hot append-only collections use a **shard-prefixed document ID** (see §5.3) to defeat index hotspotting caused by ULID monotonicity; the `id` field inside the document remains the pure ULID. + +--- + +## 4. Data dictionary + +Types: `ULID`, `STRING`, `TEXT` (long-form), `TS` (timestamp: Firestore `Timestamp` / Room epoch-millis), `DATE` (ISO `yyyy-MM-dd`), `TIME` (`HH:mm`), `INT`, `DOUBLE`, `BOOL`, `ENUM`, `JSON`. + +**Common columns (present on every entity, listed once).** Every entity carries `id ULID PK` plus the audit block `createdAt TS NOT NULL`, `updatedAt TS NOT NULL`, `deletedAt TS NULL` (soft delete, §7.3). Every entity except `Company`, `Holiday` (keyed by `calendarId`), `PayslipLine` (keyed by `payslipId`), and the two client-only tables carries `companyId ULID NOT NULL`. Room rows additionally carry `syncStatus ENUM(PENDING|SYNCED|FAILED) NOT NULL` — client-only, never serialized to the server. The tables below list entity-specific fields only. Field names with a typographic space in master spec §4 (`appliesTo Json`, `branchIds Json`, `componentIds Json`, `meta Json`) are physically stored as `appliesToJson`, `branchIdsJson`, `componentIdsJson`, `metaJson`. + +### 4.1 Org & Identity + +| Entity | Field | Type | Null | Notes | +|---|---|---|---|---| +| Company | name | STRING | N | Display name | +| Company | legalName | STRING | N | Registered legal entity name | +| Company | timezone / currency | STRING | N | IANA zone / ISO 4217 — tenant defaults | +| Company | status / plan | STRING | N | `ACTIVE`/`SUSPENDED`/`CHURNED`; billing plan code | +| Company | settingsJson | JSON | N | Tenant feature flags, punch policy, week-off config | +| Branch | name / code | STRING | N | `code` unique per company | +| Branch | address | STRING | N | Postal address | +| Branch | lat / lng | DOUBLE | N | Branch centroid (default geofence anchor) | +| Branch | radiusM | INT | N | Default geofence radius, meters | +| Branch | timezone / status | STRING | N | Overrides company zone; `ACTIVE`/`CLOSED` | +| Department | branchId | ULID | Y | Null = company-wide department | +| Department | name / code | STRING | N | `code` unique per company | +| Department | parentDepartmentId | ULID | Y | Self-reference; hierarchy, cycle-checked in API | +| Position | title / code | STRING | N | `code` unique per company | +| Position | level | INT | N | Seniority band (1 = entry) | +| Position | departmentId | ULID | Y | Optional department binding | +| Employee | employeeCode | STRING | N | Unique per company; human-readable | +| Employee | firstName / lastName | STRING | N | PII — envelope-encrypted (§7.4) | +| Employee | email / phone | STRING | N | PII — envelope-encrypted; email unique per company | +| Employee | avatarUrl | STRING | Y | Cloud Storage URL; PII | +| Employee | branchId / departmentId / positionId | ULID | N | Org placement FKs | +| Employee | managerId | ULID | Y | Self-reference → approval chain root | +| Employee | employmentType | ENUM | N | `FULL_TIME\|PART_TIME\|CONTRACT\|INTERN` | +| Employee | joinDate / exitDate | DATE | N / Y | `exitDate` set by deactivation flow | +| Employee | status | ENUM | N | `ACTIVE\|ON_LEAVE\|SUSPENDED\|EXITED` | +| Employee | authUid | STRING | N | Firebase Auth UID; unique globally | +| RoleAssignment | employeeId | ULID | N | | +| RoleAssignment | roleCode | STRING | N | Built-in or custom role code (§1.1 master spec) | +| RoleAssignment | scopeType | ENUM | N | `COMPANY\|BRANCH\|DEPARTMENT` | +| RoleAssignment | scopeId | ULID | Y | Null when scopeType=COMPANY | +| Device | employeeId | ULID | N | | +| Device | platform / model / appVersion | STRING | N | e.g. `android` / `Pixel 9` / `1.4.2` | +| Device | fcmToken | STRING | N | Push token; rotated in place | +| Device | integrityVerdict | STRING | N | Last Play Integrity verdict summary | +| Device | boundAt / revokedAt | TS | N / Y | Non-null `revokedAt` = binding revoked; punches rejected | + +### 4.2 Attendance & Scheduling + +| Entity | Field | Type | Null | Notes | +|---|---|---|---|---| +| Geofence | branchId | ULID | N | | +| Geofence | name / active | STRING / BOOL | N | | +| Geofence | lat / lng | DOUBLE | N | Centroid | +| Geofence | radiusM | INT | N | Meters; server clamps to [30, 2000] | +| Shift | name / code | STRING | N | `code` unique per company | +| Shift | startTime / endTime | TIME | N | Local to branch timezone; `isNight` handles wrap | +| Shift | breakMinutes / graceInMinutes / graceOutMinutes | INT | N | Unpaid break; lateness/early-out tolerance | +| Shift | overtimePolicyJson | JSON | N | Threshold, multiplier, rounding, cap | +| Shift | isNight / active | BOOL | N | `isNight`: end time on next calendar day | +| ShiftAssignment | employeeId / shiftId / branchId | ULID | N | | +| ShiftAssignment | date | DATE | N | Unique with `employeeId` | +| ShiftAssignment | source | ENUM | N | `ROSTER\|ROTATION\|MANUAL\|SWAP` | +| ShiftAssignment | status | STRING | N | `SCHEDULED`, `LOCKED`, `CANCELLED` | +| ShiftSwapRequest | requesterId | ULID | N | | +| ShiftSwapRequest | targetEmployeeId | ULID | Y | Null = open-shift claim pool | +| ShiftSwapRequest | assignmentId | ULID | N | FK → ShiftAssignment | +| ShiftSwapRequest | status | STRING | N | `PENDING`, `ACCEPTED`, `APPROVED`, `REJECTED`, `CANCELLED` | +| ShiftSwapRequest | decidedBy / decidedAt | ULID / TS | Y | Manager decision | +| AttendancePunch | employeeId | ULID | N | Append-only: no update/delete ever | +| AttendancePunch | punchedAt | TS | N | Client capture time; server plausibility-checked | +| AttendancePunch | type | ENUM | N | `IN\|OUT` | +| AttendancePunch | method | ENUM | N | `GPS\|QR\|FACE\|MANUAL\|KIOSK` | +| AttendancePunch | lat / lng / accuracyM | DOUBLE | Y | GPS methods only | +| AttendancePunch | geofenceId | ULID | Y | Matched fence, if any | +| AttendancePunch | insideFence | BOOL | N | Server-evaluated at write | +| AttendancePunch | deviceId | ULID | N | Bound device FK | +| AttendancePunch | kioskId / faceScore | ULID / DOUBLE | Y | QR-kiosk id / FACE embedding match score | +| AttendancePunch | photoUrl / note | STRING | Y | Optional capture (PII) / employee note | +| AttendancePunch | serverValidated | BOOL | N | False until server rules pass | +| AttendancePunch | invalidReason | STRING | Y | e.g. `GEOFENCE_VIOLATION`, `MOCK_LOCATION`, `IMPLAUSIBLE_SPEED` | +| AttendanceDay | employeeId | ULID | N | Projection; unique with `date` | +| AttendanceDay | date | DATE | N | Business date in shift timezone | +| AttendanceDay | shiftId | ULID | Y | Resolved assignment for the date | +| AttendanceDay | firstInAt / lastOutAt | TS | Y | | +| AttendanceDay | workedMinutes / breakMinutes / lateMinutes / earlyOutMinutes / overtimeMinutes | INT | N | Computed vs shift + grace + OT policy | +| AttendanceDay | status | ENUM | N | `PRESENT\|ABSENT\|HALF_DAY\|LEAVE\|HOLIDAY\|WEEK_OFF\|PENDING` | +| AttendanceDay | computedAt / version | TS / INT | N | Last recompute time; optimistic lock, bump on recompute | +| RegularizationRequest | employeeId | ULID | N | | +| RegularizationRequest | date | DATE | N | Target attendance date | +| RegularizationRequest | requestedInAt / requestedOutAt | TS | Y | At least one required (API rule) | +| RegularizationRequest | reason | TEXT | N | | +| RegularizationRequest | status | ENUM | N | `PENDING\|APPROVED\|REJECTED\|CANCELLED` | +| RegularizationRequest | approverChainJson | JSON | N | Ordered approver steps + decisions | +| RegularizationRequest | decidedBy / decidedAt | ULID / TS | Y | Final decision | + +### 4.3 Leave + +| Entity | Field | Type | Null | Notes | +|---|---|---|---|---| +| LeaveType | name / code | STRING | N | `code` unique per company (e.g. `AL`, `SL`) | +| LeaveType | colorHex | STRING | N | UI swatch | +| LeaveType | isPaid / requiresAttachment / active | BOOL | N | `isPaid` drives payroll `lopDays`; attachment e.g. medical certificate | +| LeavePolicy | leaveTypeId | ULID | N | | +| LeavePolicy | accrualRule | ENUM | N | `NONE\|MONTHLY\|YEARLY\|ANNIVERSARY` | +| LeavePolicy | accrualDays | DOUBLE | N | Days per accrual event | +| LeavePolicy | maxBalance / maxCarryover | DOUBLE | N | Caps applied by accrual engine | +| LeavePolicy | minNoticedays | INT | N | Minimum notice before startDate | +| LeavePolicy | maxConsecutiveDays | INT | N | Per-request cap | +| LeavePolicy | appliesToJson | JSON | N | Audience selector: branches/departments/employmentTypes | +| LeaveBalance | employeeId / leaveTypeId | ULID | N | Unique with `periodYear` | +| LeaveBalance | periodYear | INT | N | Balance period | +| LeaveBalance | entitledDays / accruedDays / usedDays / carriedOverDays / pendingDays | DOUBLE | N | Server-maintained; half-day granularity (0.5) | +| LeaveBalance | version | INT | N | Optimistic lock for decide/cancel transactions | +| LeaveRequest | employeeId / leaveTypeId | ULID | N | | +| LeaveRequest | startDate / endDate | DATE | N | Inclusive range | +| LeaveRequest | startHalf / endHalf | BOOL | N | Half-day flags on boundary dates | +| LeaveRequest | days | DOUBLE | N | Server-computed net of holidays/week-offs | +| LeaveRequest | reason | TEXT | N | | +| LeaveRequest | attachmentUrl | STRING | Y | Required when `LeaveType.requiresAttachment` | +| LeaveRequest | status | ENUM | N | `DRAFT\|PENDING\|APPROVED\|REJECTED\|CANCELLED` | +| LeaveRequest | approvalChainJson | JSON | N | Ordered steps: approverId, role, decision, at, comment | +| LeaveRequest | currentApproverId | ULID | Y | Head of pending chain; drives approvals inbox | +| LeaveRequest | decidedAt | TS | Y | Terminal decision time | +| HolidayCalendar | name / year | STRING / INT | N | | +| HolidayCalendar | branchIdsJson | JSON | N | Branches the calendar applies to; empty = all | +| Holiday | calendarId | ULID | N | Parent key (no `companyId`; tenancy via parent path) | +| Holiday | date | DATE | N | Unique within calendar | +| Holiday | name | STRING | N | | +| Holiday | isOptional | BOOL | N | Optional/restricted holiday | + +### 4.4 Payroll & Platform + +| Entity | Field | Type | Null | Notes | +|---|---|---|---|---| +| SalaryComponent | name / code | STRING | N | `code` unique per company (e.g. `BASIC`, `HRA`) | +| SalaryComponent | type | ENUM | N | `EARNING\|DEDUCTION\|EMPLOYER_COST` | +| SalaryComponent | calc | ENUM | N | `FIXED\|PERCENT_OF_BASIC\|PERCENT_OF_GROSS\|FORMULA` | +| SalaryComponent | value | DOUBLE | N | Amount or percent per `calc` | +| SalaryComponent | formula | STRING | Y | Expression, `calc=FORMULA` only | +| SalaryComponent | taxable / active | BOOL | N | | +| SalaryComponent | statutoryCode | STRING | Y | Hook for statutory packs (P2) | +| SalaryStructure | name | STRING | N | | +| SalaryStructure | componentIdsJson | JSON | N | Ordered component ID list | +| EmployeeSalary | employeeId / structureId | ULID | N | | +| EmployeeSalary | basicAmount | DOUBLE | N | Minor-unit-safe decimal; currency below | +| EmployeeSalary | currency | STRING | N | ISO 4217 | +| EmployeeSalary | effectiveFrom / effectiveTo | DATE | N / Y | Effective-dated, non-overlapping per employee; null `effectiveTo` = current | +| EmployeeSalary | revisionReason | STRING | N | e.g. `ANNUAL_REVIEW`, `PROMOTION` | +| PayrollRun | periodYear / periodMonth | INT | N | Unique with `branchIdsJson` scope (API-enforced) | +| PayrollRun | branchIdsJson | JSON | N | Run scope; empty = all branches | +| PayrollRun | status | ENUM | N | `DRAFT\|CALCULATING\|REVIEW\|APPROVED\|PAID\|CLOSED` | +| PayrollRun | startedBy / approvedBy | ULID | N / Y | | +| PayrollRun | totalsJson | JSON | N | Denormalized run totals: headcount, gross, net, per-component sums | +| PayrollRun | lockedAt | TS | Y | Non-null = source data frozen | +| Payslip | runId / employeeId | ULID | N | Unique pair | +| Payslip | periodYear / periodMonth | INT | N | Copied from run (query independence) | +| Payslip | currency | STRING | N | Snapshot from EmployeeSalary | +| Payslip | gross / totalDeductions / net | DOUBLE | N | | +| Payslip | workedDays / paidLeaveDays / lopDays | DOUBLE | N | From AttendanceDay + LeaveRequest projections | +| Payslip | overtimeMinutes | INT | N | | +| Payslip | status | STRING | N | `DRAFT`, `FINAL`, `VOID` | +| Payslip | pdfUrl | STRING | Y | Rendered artifact in Cloud Storage | +| PayslipLine | payslipId | ULID | N | Parent key (no `companyId`; tenancy via parent) | +| PayslipLine | componentCode / componentName / type | STRING | N | **Snapshots** of SalaryComponent at calc time (§5.5) | +| PayslipLine | amount | DOUBLE | N | Signed by `type` convention | +| PayslipLine | metaJson | JSON | N | Calc trace: base, rate, formula inputs | +| Announcement | title / body | STRING/TEXT | N | | +| Announcement | audienceJson | JSON | N | Branch/department/role selectors | +| Announcement | publishAt / expiresAt | TS | N / Y | | +| Announcement | createdBy | ULID | N | | +| Announcement | priority | STRING | N | `NORMAL`, `HIGH`, `URGENT` | +| EmployeeDocument | employeeId | ULID | N | | +| EmployeeDocument | kind | STRING | N | `ID_PROOF`, `CONTRACT`, `CERTIFICATE`, … | +| EmployeeDocument | name / storagePath / mimeType | STRING | N | Cloud Storage object | +| EmployeeDocument | sizeBytes | INT | N | | +| EmployeeDocument | expiresAt | TS | Y | Document validity (visas, permits) | +| EmployeeDocument | verifiedBy | ULID | Y | HR verifier | +| AuditLog | actorId / actorRole | ULID / STRING | N | Immutable, append-only | +| AuditLog | action | STRING | N | e.g. `employee.update`, `payroll.approve` | +| AuditLog | resourceType / resourceId | STRING / ULID | N | | +| AuditLog | beforeJson / afterJson | JSON | Y | Redacted diffs (no PII plaintext) | +| AuditLog | ip / userAgent | STRING | Y | | +| AuditLog | at | TS | N | Event time (distinct from createdAt) | +| NotificationMessage | employeeId | ULID | N | | +| NotificationMessage | kind | STRING | N | `LEAVE_DECIDED`, `PUNCH_REJECTED`, `PAYSLIP_READY`, … | +| NotificationMessage | title / body / dataJson | STRING/STRING/JSON | N | `dataJson` carries deep link | +| NotificationMessage | readAt / sentAt | TS | Y / N | | +| OutboxEntry *(client)* | opType | ENUM | N | `CREATE\|UPDATE\|DELETE` (punches: CREATE only) | +| OutboxEntry *(client)* | resourceType / resourceId | STRING / ULID | N | | +| OutboxEntry *(client)* | payloadJson | JSON | N | Serialized request body | +| OutboxEntry *(client)* | idempotencyKey | ULID | N | Sent as `Idempotency-Key` header | +| OutboxEntry *(client)* | attempts / lastError | INT / STRING | N / Y | | +| OutboxEntry *(client)* | state | ENUM | N | `PENDING\|IN_FLIGHT\|DONE\|FAILED` | +| OutboxEntry *(client)* | queuedAt | TS | N | FIFO order per resourceType | +| SyncCursor *(client)* | resourceType | STRING | N | PK (no ULID id) | +| SyncCursor *(client)* | cursor | STRING | N | Opaque server cursor | +| SyncCursor *(client)* | lastSyncedAt | TS | N | | + +--- + +## 5. Firestore physical design + +### 5.1 Collection layout + +Exactly as master spec §4.6 — one tenant root document plus flat sub-collections per aggregate: + +``` +companies/{cid} — Company doc + branches/{id} departments/{id} positions/{id} + employees/{id} roleAssignments/{id} devices/{id} + geofences/{id} shifts/{id} shiftAssignments/{id} + punches/{sid} attendanceDays/{sid} regularizations/{id} + leaveTypes/{id} leavePolicies/{id} leaveBalances/{id} + leaveRequests/{id} holidayCalendars/{id} ── holidayCalendars/{id}/holidays/{id} + salaryComponents/{id} salaryStructures/{id} employeeSalaries/{id} + payrollRuns/{id} payslips/{id} ── payslips/{id}/lines/{id} + announcements/{id} documents/{id} auditLogs/{sid} + notifications/{id} +``` + +- `{id}` = ULID; `{sid}` = shard-prefixed ULID (§5.3). +- `Holiday` and `PayslipLine` are the only nested sub-sub-collections; both are small, parent-bounded child sets always read with their parent. +- Firestore security rules deny all direct client writes to these collections and allow reads only for a narrow self-service subset (own notifications, active announcements); everything else flows through the REST API (master spec §7). + +### 5.2 Composite index plan + +Firestore auto-indexes single fields; the composite entries below are declared in `firestore.indexes.json`. All are collection-scope within the tenant sub-collection (tenant isolation is structural), plus collection-group entries where BigQuery/ops tooling needs cross-tenant scans. + +| Collection | Index (order matters) | Query served | +|---|---|---| +| punches | `employeeId ASC, punchedAt DESC` | `GET /attendance/punches?employeeId` history, day recompute fan-in | +| punches | `deviceId ASC, punchedAt DESC` | Device forensics, speed-of-travel plausibility lookback | +| punches | `serverValidated ASC, punchedAt DESC` | Invalid-punch review queue | +| attendanceDays | `employeeId ASC, date DESC` | `GET /attendance/days?employeeId&from&to` | +| attendanceDays | `date ASC, status ASC` | Daily branch/company presence dashboards | +| attendanceDays | `status ASC, updatedAt DESC` | Pending-computation sweep; anomaly review | +| shiftAssignments | `employeeId ASC, date ASC` | Employee roster view; punch-time shift resolution | +| shiftAssignments | `branchId ASC, date ASC` | `GET /rosters?branchId&from&to` grid | +| regularizations | `status ASC, updatedAt DESC` | Approvals inbox | +| regularizations | `employeeId ASC, date DESC` | Employee history | +| leaveRequests | `employeeId ASC, startDate DESC` | Self-service list | +| leaveRequests | `currentApproverId ASC, status ASC, updatedAt DESC` | Approvals inbox (pending-for-me) | +| leaveRequests | `status ASC, updatedAt DESC` | HR review queues | +| leaveBalances | `employeeId ASC, periodYear DESC` | `GET /leave/balances?employeeId` | +| payslips | `employeeId ASC, periodYear DESC, periodMonth DESC` | `GET /payslips?employeeId&year` | +| payslips | `runId ASC, status ASC` | Run review screen | +| notifications | `employeeId ASC, sentAt DESC` | `GET /notifications` | +| auditLogs | `resourceType ASC, at DESC` | `GET /audit-logs?resourceType&from&to` | +| auditLogs | `actorId ASC, at DESC` | Actor-centric audit review | +| *every synced collection* | `updatedAt ASC, __name__ ASC` | `GET /sync/pull` delta scan with stable tie-break | + +The `(employeeId, date)`, `(status, updatedAt)`, `(updatedAt)` families mandated by master spec §4.6 are the first three rows of each group above. + +### 5.3 Write sharding for hot paths + +At 100k active employees a tenant produces ~200k+ punches/day, concentrated in shift-start bursts (≈2–5k writes/min for 15-minute windows). Two Firestore hotspots must be engineered around: (a) sustained write rates to a collection whose **document IDs are monotonically increasing** (ULIDs are), and (b) single-field index ranges on monotonically increasing values (`punchedAt`, `updatedAt`). + +Mitigations, applied to `punches`, `attendanceDays`, and `auditLogs`: + +1. **Shard-prefixed document IDs.** Document ID = `s{NN}_{ulid}` where `NN = crc32(employeeId) mod 32`, zero-padded. Writes spread across 32 key ranges; the pure ULID remains in the `id` field and in the API. Reads are unaffected: every production query on these collections filters by `employeeId`, `deviceId`, or an indexed field — never by document ID range. +2. **Burst absorption via Pub/Sub.** The punch API path does one document write (the punch) synchronously; `AttendanceDay` recomputation is fanned out through Pub/Sub with per-employee ordering keys and batched (debounce 30s), so the projection collection sees at most one write per employee per burst instead of one per punch. +3. **No sequential-index range scans on the hot path.** The sync `updatedAt ASC` scan is issued per-tenant with cursor + limit (≤500), which Firestore serves without hotspotting; company-wide dashboards read pre-aggregated KPI docs (below), not raw punches. +4. **Aggregate documents with sharded counters.** Daily per-branch presence counters (`present`, `late`, `absent`) live in 16 counter shards per branch-day, summed on read by the analytics endpoints. + +### 5.4 Document ID and query discipline + +- Never query across tenants at runtime; collection-group queries are reserved for offline jobs (BigQuery export backfill, SUPER_ADMIN tooling). +- All list endpoints translate to a single composite-index query + cursor (`startAfter`), never `OFFSET`-style skips. +- Multi-entity invariants (leave decide + balance debit; payroll approve + payslip finalize) run in Firestore transactions with `version` preconditions on projection docs. + +### 5.5 Denormalization decisions + +| Duplicated data | Where | Why | Reconciliation | +|---|---|---|---| +| `componentCode`, `componentName`, `type` | `PayslipLine` (from SalaryComponent) | Payslips are legal artifacts; must render identically forever even if the component is renamed or deleted | Never — snapshot is intentional and immutable once `Payslip.status=FINAL` | +| `employeeName`, `employeeCode` snapshot | `Payslip` (additive snapshot fields; from Employee) | Same immutability requirement; also survives GDPR crypto-shredding as pseudonymized payroll record (§7.4) | Never after FINAL | +| `insideFence`, `geofenceId` | `AttendancePunch` (from Geofence evaluation) | Punch validity must reflect the fence **as it was at punch time**; fences change | Never — append-only | +| `branchId` | `ShiftAssignment` (from Employee/roster context) | Roster queries by branch without joining employees | Roster write path sets it | +| `days` | `LeaveRequest` (derivable from dates + calendar) | Balance math and approver UX need the server-computed figure; holiday calendars change | Recomputed only on request edit while `DRAFT` | +| `periodYear`, `periodMonth` | `Payslip` (from PayrollRun) | Employee payslip list queries without run lookup | Copied at creation | +| `totalsJson` | `PayrollRun` (sum of payslips) | Review screen reads one doc, not 100k payslips | Rebuilt by calculation job; frozen at `lockedAt` | +| `title`, `body` | `NotificationMessage` (from source event) | Notification must render after source mutation/deletion | Never | +| Role/branch claims `{cid, r, b, eid}` | Firebase Auth custom claims (from RoleAssignment) | Zero-read authz on every request | Claims rebuilt on RoleAssignment change; ≤1h propagation via forced token refresh | +| `AttendanceDay` (entire entity) | Projection of punches × shifts × leave × holidays | O(1) reads for calendars, payroll input, KPIs | Recomputed on any contributing event; `version`-guarded | + +--- + +## 6. Room schema (Android) + +### 6.1 On-device tables and retention windows + +Room holds the **current user's slice** of the tenant, not the tenant. All tables carry the common columns incl. `syncStatus`. DAOs expose `Flow`s; repositories never read the network directly (master spec §6.3). + +| Table | Scope on device | Local retention | Notes | +|---|---|---|---| +| `employees` | Self + org directory (id, name, avatar, position, branch — no PII beyond directory fields) | Directory: full; refreshed via sync | Approvers additionally cache direct reports | +| `branches`, `departments`, `positions` | All active | Full | Small reference data | +| `devices` | Own bindings | Full | | +| `geofences` | Own branch's active fences | Full | Needed for punch pre-check UX (client hint only; server re-validates) | +| `shifts` | All active | Full | | +| `shift_assignments` | Own, date ∈ [today−30d, today+30d] | 60-day sliding window | | +| `punches` | Own | **90 days** | Append-only; local rows past window purged by `RetentionWorker` | +| `attendance_days` | Own | 90 days | | +| `regularization_requests` | Own + pending-for-me (approvers) | 180 days | | +| `leave_types`, `leave_policies` | All active | Full | | +| `leave_balances` | Own, current + previous periodYear | 2 periods | | +| `leave_requests` | Own + pending-for-me (approvers) | 365 days | | +| `holiday_calendars`, `holidays` | Applicable to own branch, current + next year | 2 years | | +| `payslips` (+ `payslip_lines`) | Own | 24 months | PDF fetched on demand, not stored | +| `announcements` | Active, audience-matched | Until `expiresAt` + 30d | | +| `notifications` | Own | 90 days | | +| `outbox_entries` | Client-only | Until `DONE` + 7d (diagnostics) | §6.2 | +| `sync_cursors` | Client-only | Permanent | One row per synced resourceType | + +**Not on device:** `roleAssignments` (own effective permissions cached in DataStore from `GET /me`, not Room), `auditLogs`, `salaryComponents`, `salaryStructures`, `employeeSalaries`, `payrollRuns`, `documents` metadata beyond own list. Salary configuration and audit data never leave the server to reduce device exposure. + +### 6.2 Client-only tables + +- **`outbox_entries`** — the mutation queue (fields in §4.4). Unique index on `idempotencyKey`; partial index on `(state, queuedAt)` for FIFO drain per `resourceType`. `SyncWorker` transitions `PENDING → IN_FLIGHT → DONE|FAILED`; `FAILED` ops surface as actionable notifications and are never silently dropped. +- **`sync_cursors`** — one opaque cursor per resourceType, advanced only after a pull page is fully applied in a Room transaction (crash-safe resume). + +### 6.3 Schema management + +Room `version` tracked in `core:database`; destructive migrations forbidden in release builds — every schema change ships a `Migration` with an instrumentation test against exported schemas (`schemas/` directory committed). `RetentionWorker` (WorkManager, daily, charging-preferred) enforces the windows above with `DELETE` by watermark — local purge only, never synced. + +--- + +## 7. Data lifecycle + +### 7.1 Retention (server) + +| Data | Hot (Firestore) | Archive (BigQuery) | Basis | +|---|---|---|---| +| Punches | 13 months | 7 years | Payroll evidence, labor-law audit | +| AttendanceDays | 25 months | 7 years | Year-over-year analytics | +| Leave requests/balances | 25 months | 7 years | Dispute resolution | +| Payslips, payroll runs | Life of tenant | 10 years | Statutory financial retention | +| Audit logs | 13 months | 7 years | SOC 2 | +| Notifications | 6 months | — | Ephemeral | +| Devices (revoked) | 12 months after `revokedAt` | — | Fraud forensics | +| Face embeddings | Life of employment; deleted at exit + 30d | Never exported | Master spec §7 | + +A scheduled `retentionSweep` job (Cloud Scheduler, nightly, per-tenant fan-out via Cloud Tasks) deletes Firestore docs past their hot window **after** confirming the BigQuery row exists. + +### 7.2 Archival to BigQuery + +- Continuous export: Firestore change streams → Pub/Sub → a streaming loader into per-entity BigQuery tables (`worktrack_raw.{collection}`), partitioned by ingestion date, clustered on `(companyId, employeeId)` where applicable. +- BigQuery is the substrate for the analytics endpoints' offline aggregates, AI insights (P4), and the long-term archive; it is never read on interactive API paths. +- Deletions propagate as tombstone rows (`deletedAt` set), so BigQuery is append-only and auditable; GDPR erasure is handled by crypto-shredding (§7.4), not row deletion. + +### 7.3 Soft delete + +- Deletable entities set `deletedAt` (never physical delete on the interactive path). All list queries filter `deletedAt == null`; direct GET of a soft-deleted resource returns the RFC 7807 `NOT_FOUND` problem (see `04-api-design.md`). +- Soft-deleted docs still flow through `GET /sync/pull` as tombstones (`op: "TOMBSTONE"`), which is how clients learn to remove local rows. +- Append-only entities (`AttendancePunch`, `AuditLog`) are **never** deleted or tombstoned inside the retention window; invalidation is expressed by `serverValidated=false` + `invalidReason`. +- Physical deletion happens only in `retentionSweep` (past hot window) or DSR fulfillment. + +### 7.4 GDPR erasure — crypto-shredding + +PII fields (`Employee.firstName/lastName/email/phone/avatarUrl`, `AttendancePunch.photoUrl` blobs, `EmployeeDocument` blobs, face embeddings) are envelope-encrypted with a **per-employee data encryption key (DEK)** stored in a `keyring` collection, itself wrapped by a Cloud KMS key (CMEK-capable per master spec §7). + +Erasure flow (DSR endpoint, P3): + +1. Verify request scope; place a legal-hold check (open payroll disputes block erasure of payroll-relevant identity). +2. Destroy the employee's DEK (KMS `Destroy` on the wrapping material + delete keyring doc). All encrypted PII — in Firestore, in backups, and in BigQuery exports — becomes unrecoverable simultaneously, without touching the archive. +3. Overwrite plaintext directory projections (name on directory cache, notification bodies) with `"Erased User"`; payslip snapshots keep `employeeCode` (pseudonym) and drop the name snapshot where statute permits, otherwise retain under the statutory-retention lawful basis. +4. Delete Cloud Storage objects (avatar, documents, face embeddings) and revoke devices. +5. Write an `AuditLog` entry (`action: "gdpr.erase"`) containing only pseudonymous identifiers. + +Backups therefore need no rewrite: restoring a backup restores ciphertext whose key no longer exists. diff --git a/docs/04-api-design.md b/docs/04-api-design.md new file mode 100644 index 0000000..24ad34c --- /dev/null +++ b/docs/04-api-design.md @@ -0,0 +1,582 @@ +# WorkTrack — REST API Design (v1) + +Version: 1.0 · Status: Approved · Owners: Platform Architecture · Derives from: `00-master-spec.md` (§2, §3, §5, §7); entity schemas in `03-database-design.md` + +**Purpose.** This document is the binding contract for the WorkTrack REST API served by Cloud Functions (Node 20, TypeScript, Express) at `https://api.worktrack.app/v1` and consumed by the Android app and the Web Admin SPA. It defines the cross-cutting conventions (authentication, tenancy, errors, pagination, idempotency, versioning, rate limits), the complete endpoint reference for every route in master spec §5 with permissions and schemas, full request/response examples for the critical flows, sequence diagrams for the four hardest interactions, and the P4 webhook design. Field names and types are those of `03-database-design.md`; nothing here redefines the data model. + +--- + +## 1. Conventions + +### 1.1 Base URL, transport, media types + +- Base: `https://api.worktrack.app/v1`. TLS 1.2+ only. All bodies are `application/json; charset=utf-8`; errors are `application/problem+json`. +- Timestamps: RFC 3339 UTC (`2026-07-17T09:02:11.482Z`). Business dates: `yyyy-MM-dd`, interpreted in the relevant branch timezone. Monetary amounts: JSON numbers with at most 2 fraction digits in the resource `currency`. +- All IDs are ULIDs (26-char Crockford base32). + +### 1.2 Authentication and tenancy + +- `Authorization: Bearer ` on every request (no exceptions; there are no anonymous routes). +- Middleware chain per master spec §7: **verify token → load tenant context → RBAC permission check → handler**; deny-by-default. +- Tenant is resolved from the verified custom claims `{ cid, r, b, eid }` — never from the URL alone. Any resource whose `companyId` differs from `cid` yields `TENANT_MISMATCH` (not `NOT_FOUND`, to make cross-tenant probing visible in audit logs; the response body carries no resource data). +- Punch and device endpoints additionally require a non-revoked `Device` binding and an acceptable Play Integrity verdict (master spec §7); failures map to `PERMISSION_DENIED` with `detail` explaining the integrity gate. + +### 1.3 Permission model + +Permissions are `resource:action` strings (master spec §1.1), bundled into roles. The reference below lists the permission each endpoint requires. **Scope is orthogonal to the permission string**: the RBAC layer intersects the permission with the caller's `RoleAssignment` scope (`COMPANY | BRANCH | DEPARTMENT`) and with self-scope for `EMPLOYEE`-role access (e.g. `payslip:read` as EMPLOYEE returns only `employeeId == eid`). `AUDITOR` holds the `:read` set plus `audit:read`. `SUPER_ADMIN` bypasses tenant scoping via internal tooling only — never through this public surface. + +### 1.4 Errors — RFC 7807 `problem+json` + +Every non-2xx response is a problem document: + +```json +{ + "type": "https://api.worktrack.app/errors/geofence-violation", + "title": "Punch outside geofence", + "status": 422, + "code": "GEOFENCE_VIOLATION", + "detail": "Location is 412 m from geofence 'HQ Tower' (radius 150 m).", + "instance": "/v1/attendance/punches", + "traceId": "8f4c1b2e9d3a4f60", + "errors": [ { "field": "lat", "reason": "OUTSIDE_FENCE" } ] +} +``` + +`code` is the machine-stable contract; `type`/`title`/`detail` may evolve. `errors[]` appears only on validation failures. Canonical codes: + +| `code` | HTTP | Meaning | Client action | +|---|---|---|---| +| `UNAUTHENTICATED` | 401 | Missing/expired/invalid ID token | Refresh token via Firebase SDK, retry once | +| `PERMISSION_DENIED` | 403 | Authenticated but lacks permission, scope, or device/integrity gate | Do not retry; surface to user | +| `TENANT_MISMATCH` | 403 | URL/body `companyId` ≠ token claim `cid` | Do not retry; forces re-login | +| `VALIDATION_FAILED` | 400 | Body/query fails schema or business validation | Fix input; `errors[]` lists fields | +| `IDEMPOTENCY_REPLAY` | 409 | `Idempotency-Key` reused with a **different** payload | Bug on client; do not retry | +| `GEOFENCE_VIOLATION` | 422 | GPS punch outside every active fence (and policy forbids) | Show distance hint; allow note/regularization | +| `KIOSK_TOKEN_INVALID` | 422 | QR token signature/window/branch check failed | Rescan fresh QR | +| `CONFLICT` | 409 | State conflict: version mismatch, duplicate natural key, illegal status transition | Re-read resource, reconcile, maybe retry | +| `RATE_LIMITED` | 429 | Tier budget exhausted | Back off per `Retry-After` | +| `NOT_FOUND` | 404 | Resource absent or soft-deleted within tenant | Remove local copy on sync | + +### 1.5 Pagination envelope + +All list endpoints are cursor-based: `?cursor=&limit=<1..200, default 50>`. Responses always use the envelope: + +```json +{ "data": [ … ], "meta": { "cursor": "eyJ1IjoiMjAyNi0w…", "hasMore": true } } +``` + +`meta.cursor` is opaque, resource-specific, valid ≥24h, and `null` on the last page. Single-resource responses use `{ "data": {…}, "meta": {} }`. Cursors encode an index position (`updatedAt` + doc name tie-break), never an offset. + +### 1.6 Idempotency + +- `Idempotency-Key: ` is honored on **all POSTs** and required on the mutation POSTs the Android outbox emits (`/attendance/punches`, `/leave/requests`, `/attendance/regularizations`, `/shift-swaps`, `/sync/push`, `/payroll/runs`, decide/cancel endpoints). +- The server persists `(cid, key) → response` for 48h. Same key + byte-identical payload ⇒ the stored response is replayed with `Idempotency-Replayed: true` and the original status code. Same key + different payload ⇒ `409 IDEMPOTENCY_REPLAY`. +- Inside `POST /sync/push`, each op's `opId` is its idempotency key (per-op dedupe); the request-level header dedupes the whole batch. + +### 1.7 Versioning and deprecation + +- Path-versioned (`/v1`). Evolution is **additive only**: new optional fields, new endpoints, new enum values (clients must tolerate unknown enum values and unknown fields). +- Breaking changes require `/v2`. A deprecated endpoint or version emits `Deprecation: true` and `Sunset: ` headers for a minimum **180-day** window, is announced in release notes, and is monitored for traffic before removal. +- Enum value retirement follows the same 180-day rule with dual-emit. + +### 1.8 Rate limiting + +Enforced per token (per device for kiosk role), fixed-window with burst allowance. Headers on every response: `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`; 429s add `Retry-After`. + +| Tier | Applies to | Sustained | Burst | +|---|---|---|---| +| Interactive | All GET/POST from user tokens | 60 req/min | 120 | +| Sync | `/sync/push`, `/sync/pull` | 12 req/min | 24 | +| Punch | `POST /attendance/punches` | 6 req/min | 10 | +| Admin bulk | Org CRUD, rosters PUT, payroll | 120 req/min | 240 | +| Kiosk | `KIOSK`-role token endpoints | 30 req/min per device | 60 | + +--- + +## 2. Endpoint reference + +Notation: request/response schemas use `field: type` shorthand; `?` marks optional/nullable. Resource schemas (full field lists) are those of the data dictionary in `03-database-design.md`; server-managed fields (`id` unless client-minted, `companyId`, `createdAt`, `updatedAt`, `deletedAt`, computed fields) are never accepted in request bodies and always present in responses. Every endpoint can return `UNAUTHENTICATED`, `PERMISSION_DENIED`, `TENANT_MISMATCH`, `RATE_LIMITED`; the Errors column lists only endpoint-specific cases. + +### 2.1 Session & devices + +| Endpoint | Permission | Request | Success | Errors | +|---|---|---|---|---| +| `GET /me` | *(any authenticated)* | — | `200` `{ employee: Employee, company: Company, roles: [{roleCode, scopeType, scopeId?}], permissions: [string], device?: Device }` | — | +| `POST /devices` | `device:bind` | `{ id: ulid, platform: string, model: string, appVersion: string, fcmToken: string, integrityToken: string }` | `201` `Device` | `VALIDATION_FAILED` (integrity verdict unacceptable), `CONFLICT` (binding limit reached) | +| `DELETE /devices/{id}` | `device:revoke` | — | `204` | `NOT_FOUND` | + +`GET /me` is the client bootstrap: it returns the effective permission set (mirrored client-side for UX only — enforcement is server-side) and is cached in DataStore, not Room. + +### 2.2 Org + +CRUD follows one pattern per resource — `GET /{res}` (list, cursor), `GET /{res}/{id}`, `POST /{res}`, `PUT /{res}/{id}`, `DELETE /{res}/{id}` (soft delete): + +| Resource | Permissions (list/read · create · update · delete) | Create/update body | Notes | +|---|---|---|---| +| `/branches` | `branch:read` · `branch:create` · `branch:update` · `branch:delete` | `{ name, code, address, lat, lng, radiusM, timezone, status }` | `CONFLICT` on duplicate `code` | +| `/departments` | `department:read` · `department:create` · `department:update` · `department:delete` | `{ name, code, branchId?, parentDepartmentId? }` | `VALIDATION_FAILED` on hierarchy cycle | +| `/positions` | `position:read` · `position:create` · `position:update` · `position:delete` | `{ title, code, level, departmentId? }` | | +| `/employees` | `employee:read` · `employee:create` · `employee:update` · `employee:delete` | `{ employeeCode, firstName, lastName, email, phone, avatarUrl?, branchId, departmentId, positionId, managerId?, employmentType, joinDate }` | Create provisions the Firebase Auth user and claims; `CONFLICT` on duplicate `employeeCode`/`email` | + +- `GET /employees?branchId&departmentId&status&q&cursor&limit` — directory search; `q` matches name/code prefix. `200` `{ data: [Employee], meta: { cursor } }`. +- `POST /employees/{id}/deactivate` — `employee:deactivate`. Body `{ exitDate: date, reason: string }`. Sets `status=EXITED`, `exitDate`, revokes devices and refresh tokens, cancels future shift assignments and pending requests. `200` `Employee`. Errors: `CONFLICT` (already `EXITED`), `NOT_FOUND`. + +### 2.3 Attendance + +| Endpoint | Permission | Request | Success | Errors | +|---|---|---|---|---| +| `POST /attendance/punches` | `attendance:punch` | see §3.1 | `201` `AttendancePunch` | `VALIDATION_FAILED`, `GEOFENCE_VIOLATION`, `KIOSK_TOKEN_INVALID`, `PERMISSION_DENIED` (device revoked / integrity), `CONFLICT` (duplicate direction within debounce) | +| `GET /attendance/punches?employeeId&from&to&cursor&limit` | `attendance:read` | — | `200` `{ data: [AttendancePunch], meta }` | `VALIDATION_FAILED` (range > 92 days) | +| `GET /attendance/days?from&to&employeeId&cursor&limit` | `attendance:read` | — | `200` `{ data: [AttendanceDay], meta }` | `VALIDATION_FAILED` | +| `POST /attendance/regularizations` | `attendance:regularize` | `{ id: ulid, date, requestedInAt?, requestedOutAt?, reason }` (≥1 timestamp) | `201` `RegularizationRequest` (`status=PENDING`, chain built) | `VALIDATION_FAILED`, `CONFLICT` (open request exists for date) | +| `POST /attendance/regularizations/{id}/decide` | `attendance:approve` | `{ decision: "APPROVE"\|"REJECT", comment?: string }` | `200` `RegularizationRequest`; on final APPROVE emits synthetic `MANUAL` punches and recomputes the day | `NOT_FOUND`, `CONFLICT` (not pending / not current approver), `VALIDATION_FAILED` | + +### 2.4 Shifts & rosters + +| Endpoint | Permission | Request | Success | Errors | +|---|---|---|---|---| +| CRUD `/shifts` | `shift:read` / `shift:create` / `shift:update` / `shift:delete` | `{ name, code, startTime, endTime, breakMinutes, graceInMinutes, graceOutMinutes, overtimePolicyJson, isNight, active }` | standard | `CONFLICT` (duplicate `code`; delete with future assignments) | +| `GET /rosters?branchId&from&to` | `roster:read` | — | `200` `{ data: [ShiftAssignment], meta }` grouped client-side into the grid | `VALIDATION_FAILED` (range > 62 days) | +| `PUT /rosters?branchId&from&to` | `roster:write` | `{ assignments: [{ id: ulid, employeeId, shiftId, date, source }] }` — full replacement of the window | `200` `{ applied: int, removed: int }` | `VALIDATION_FAILED` (employee not in branch; overlapping night shifts), `CONFLICT` (window locked) | +| `POST /shift-swaps` | `shift_swap:create` | `{ id: ulid, assignmentId, targetEmployeeId? }` | `201` `ShiftSwapRequest` (`status=PENDING`) | `VALIDATION_FAILED` (past date), `CONFLICT` (assignment locked / already swapped) | +| `POST /shift-swaps/{id}/decide` | `shift_swap:decide` | `{ decision: "APPROVE"\|"REJECT", comment? }` | `200` `ShiftSwapRequest`; APPROVE rewrites both assignments with `source=SWAP` | `NOT_FOUND`, `CONFLICT` | + +### 2.5 Leave + +| Endpoint | Permission | Request | Success | Errors | +|---|---|---|---|---| +| `GET /leave/types` | `leave:read` | — | `200` `{ data: [LeaveType], meta }` | — | +| `GET /leave/balances?employeeId` | `leave:read` | — | `200` `{ data: [LeaveBalance], meta }` (current `periodYear`) | `NOT_FOUND` | +| `POST /leave/requests` | `leave:request` | see §3.2 | `201` `LeaveRequest` | `VALIDATION_FAILED` (notice/consecutive/attachment/policy), `CONFLICT` (overlap or insufficient balance) | +| `GET /leave/requests?employeeId&status&from&to&pendingForMe&cursor&limit` | `leave:read` | — | `200` `{ data: [LeaveRequest], meta }`; `pendingForMe=true` = approvals inbox | — | +| `POST /leave/requests/{id}/decide` | `leave:approve` | see §3.3 | `200` `LeaveRequest` | `NOT_FOUND`, `CONFLICT` (not pending / not current approver / balance version race) | +| `POST /leave/requests/{id}/cancel` | `leave:request` (self) or `leave:approve` | `{ reason?: string }` | `200` `LeaveRequest` (`status=CANCELLED`, pending/used days released) | `NOT_FOUND`, `CONFLICT` (already terminal; past-dated beyond policy) | + +### 2.6 Payroll + +| Endpoint | Permission | Request | Success | Errors | +|---|---|---|---|---| +| `GET /payroll/runs?periodYear&status&cursor&limit` | `payroll:read` | — | `200` `{ data: [PayrollRun], meta }` | — | +| `POST /payroll/runs` | `payroll:run` | see §3.6 | `202` `PayrollRun` (`status=CALCULATING`; async via Cloud Tasks) | `VALIDATION_FAILED`, `CONFLICT` (overlapping run for period/branches) | +| `POST /payroll/runs/{id}/approve` | `payroll:approve` | `{ comment?: string }` | `200` `PayrollRun` (`status=APPROVED`, `approvedBy` set; payslips finalize + PDFs render async) | `NOT_FOUND`, `CONFLICT` (status ≠ `REVIEW`) | +| `GET /payslips?employeeId&year&cursor&limit` | `payslip:read` | — | `200` `{ data: [Payslip], meta }` (self-scoped for EMPLOYEE) | — | +| `GET /payslips/{id}` | `payslip:read` | — | `200` `{ data: { …Payslip, lines: [PayslipLine] }, meta: {} }` | `NOT_FOUND` | + +### 2.7 Comms, analytics, audit + +| Endpoint | Permission | Request | Success | Errors | +|---|---|---|---|---| +| `GET /announcements?activeOnly&cursor&limit` | `announcement:read` | — | `200` list (audience-filtered) | — | +| `POST /announcements` | `announcement:create` | `{ title, body, audienceJson, publishAt, expiresAt?, priority }` | `201` `Announcement` | `VALIDATION_FAILED` | +| `GET /notifications?unreadOnly&cursor&limit` | `notification:read` (self) | — | `200` `{ data: [NotificationMessage], meta }` | — | +| `POST /notifications/{id}/read` | `notification:read` (self) | — | `200` `NotificationMessage` (`readAt` set; idempotent) | `NOT_FOUND` | +| `GET /analytics/kpis?scope&period` | `analytics:read` | `scope`: `company\|branch:{id}\|department:{id}`; `period`: `yyyy-MM` or `yyyy-'W'ww` | `200` `{ data: { headcount, presentRate, lateRate, absenceRate, avgOvertimeMinutes, leaveUtilization, payrollCost? }, meta: {} }` | `VALIDATION_FAILED` | +| `GET /analytics/insights` | `analytics:read` | — | `200` `{ data: [{ kind, severity, subjectType, subjectId, summary, evidenceJson, generatedAt }], meta }` (P4 populates) | — | +| `GET /audit-logs?resourceType&from&to&actorId&cursor&limit` | `audit:read` | — | `200` `{ data: [AuditLog], meta }` | `VALIDATION_FAILED` (range > 92 days) | + +### 2.8 Sync + +| Endpoint | Permission | Request | Success | Errors | +|---|---|---|---|---| +| `POST /sync/push` | `sync:push` | see §3.4 | `200` per-op results (batch never fails atomically) | `VALIDATION_FAILED` (malformed batch; >100 ops) | +| `GET /sync/pull?types&cursor&limit` | `sync:pull` | `types`: CSV of resourceTypes | `200` see §3.5 | `VALIDATION_FAILED` (unknown type; expired cursor ⇒ client resets cursor and re-pulls) | + +--- + +## 3. Critical flow examples + +### 3.1 `POST /attendance/punches` + +**GPS variant** — headers `Authorization`, `Idempotency-Key: 01J2Q9F1QZJ8M4V0T8B3N7XW5D`: + +```json +{ + "id": "01J2Q9F1QZJ8M4V0T8B3N7XW5D", + "type": "IN", + "method": "GPS", + "punchedAt": "2026-07-17T09:02:11.482Z", + "lat": 25.197197, + "lng": 55.274376, + "accuracyM": 12.4, + "isMock": false, + "deviceId": "01HZX0K3T9RCB6W2P5M8Q4JD7E", + "note": null +} +``` + +`201 Created`: + +```json +{ + "data": { + "id": "01J2Q9F1QZJ8M4V0T8B3N7XW5D", + "companyId": "01HV5M2K8XQ4T9WBCJ6R3ZP0YA", + "employeeId": "01HW8N4T2YV6RDK9Q1XB5MJ3PC", + "punchedAt": "2026-07-17T09:02:11.482Z", + "type": "IN", + "method": "GPS", + "lat": 25.197197, "lng": 55.274376, "accuracyM": 12.4, + "geofenceId": "01HVQ7R2M5XT8B4WNJ0K6YD3PZ", + "insideFence": true, + "deviceId": "01HZX0K3T9RCB6W2P5M8Q4JD7E", + "kioskId": null, "faceScore": null, "photoUrl": null, "note": null, + "serverValidated": true, + "invalidReason": null, + "createdAt": "2026-07-17T09:02:12.010Z", + "updatedAt": "2026-07-17T09:02:12.010Z" + }, + "meta": {} +} +``` + +Failure (`422`, `application/problem+json`): `code: "GEOFENCE_VIOLATION"` as shown in §1.4. Note: tenant policy (`Company.settingsJson`) may instead persist the punch with `serverValidated=false, invalidReason="GEOFENCE_VIOLATION"` and return `201` — the problem response is for the strict-policy default. + +**QR kiosk variant** — same endpoint, token replaces coordinates: + +```json +{ + "id": "01J2QA0C3VKXW8N5T1RD9B6MYF", + "type": "IN", + "method": "QR", + "punchedAt": "2026-07-17T09:03:40.115Z", + "kioskToken": "v1.01HVKQ8SK2M7X4TB9WRC5J0DNP.58913127.Gm4qXcVb9tE2LkAzR7yPwQ1sHj8UfNd3oZ6TeKvB0aY", + "deviceId": "01HZX0K3T9RCB6W2P5M8Q4JD7E" +} +``` + +`kioskToken` = `v1...` where `window = floor(epochSeconds / 30)`. Server verification: signature, window skew ≤ ±1, kiosk branch == employee branch. Success mirrors the GPS response with `kioskId` set and `lat/lng` null; failure is `422 KIOSK_TOKEN_INVALID`. + +### 3.2 `POST /leave/requests` + +```json +{ + "id": "01J2QB7H5PWXK2M9V4TC8N1RDF", + "leaveTypeId": "01HVL3A9Q6XT2M8KRB5W7JD0PY", + "startDate": "2026-08-03", + "endDate": "2026-08-05", + "startHalf": false, + "endHalf": true, + "reason": "Family travel", + "attachmentUrl": null +} +``` + +`201 Created` — server computed `days` (2.5: three days minus the Aug 5 half, no holidays in range), built the chain, debited `pendingDays`: + +```json +{ + "data": { + "id": "01J2QB7H5PWXK2M9V4TC8N1RDF", + "companyId": "01HV5M2K8XQ4T9WBCJ6R3ZP0YA", + "employeeId": "01HW8N4T2YV6RDK9Q1XB5MJ3PC", + "leaveTypeId": "01HVL3A9Q6XT2M8KRB5W7JD0PY", + "startDate": "2026-08-03", "endDate": "2026-08-05", + "startHalf": false, "endHalf": true, + "days": 2.5, + "reason": "Family travel", + "attachmentUrl": null, + "status": "PENDING", + "approvalChainJson": [ + { "step": 1, "approverId": "01HX2K7M9QTB4W6RCJ3N8VD5PZ", "roleCode": "TEAM_LEAD", "decision": null, "decidedAt": null, "comment": null }, + { "step": 2, "approverId": "01HX9P4R2MKV7T3WBQ8C6JN0YD", "roleCode": "BRANCH_MANAGER", "decision": null, "decidedAt": null, "comment": null } + ], + "currentApproverId": "01HX2K7M9QTB4W6RCJ3N8VD5PZ", + "decidedAt": null, + "createdAt": "2026-07-17T10:15:03.271Z", + "updatedAt": "2026-07-17T10:15:03.271Z" + }, + "meta": {} +} +``` + +Errors: `400 VALIDATION_FAILED` (`minNoticedays` violated, `maxConsecutiveDays` exceeded, attachment missing while `requiresAttachment`), `409 CONFLICT` (overlapping request, or `pendingDays + usedDays` would exceed balance). + +### 3.3 `POST /leave/requests/{id}/decide` + +```json +{ "decision": "APPROVE", "comment": "Enjoy the trip" } +``` + +`200 OK` (intermediate step — chain advances): + +```json +{ + "data": { + "id": "01J2QB7H5PWXK2M9V4TC8N1RDF", + "status": "PENDING", + "approvalChainJson": [ + { "step": 1, "approverId": "01HX2K7M9QTB4W6RCJ3N8VD5PZ", "roleCode": "TEAM_LEAD", "decision": "APPROVE", "decidedAt": "2026-07-17T11:40:22.905Z", "comment": "Enjoy the trip" }, + { "step": 2, "approverId": "01HX9P4R2MKV7T3WBQ8C6JN0YD", "roleCode": "BRANCH_MANAGER", "decision": null, "decidedAt": null, "comment": null } + ], + "currentApproverId": "01HX9P4R2MKV7T3WBQ8C6JN0YD", + "decidedAt": null, + "updatedAt": "2026-07-17T11:40:22.905Z" + }, + "meta": {} +} +``` + +When the **final** approver approves: transaction moves `days` from `pendingDays` to `usedDays` on `LeaveBalance` (guarded by `version`), sets `status=APPROVED`, `currentApproverId=null`, `decidedAt`, marks affected `AttendanceDay` rows `LEAVE`, and notifies the employee. A `REJECT` at any step is terminal: `status=REJECTED`, `pendingDays` released. `409 CONFLICT` if the caller is not `currentApproverId` or the request already reached a terminal status. + +### 3.4 `POST /sync/push` + +Batched outbox drain (≤100 ops, FIFO per resourceType). `opId` is the per-op idempotency key (the outbox row's `idempotencyKey`): + +```json +{ + "deviceId": "01HZX0K3T9RCB6W2P5M8Q4JD7E", + "ops": [ + { + "opId": "01J2QC1M8TWXV5K2N9RB4D7PYF", + "opType": "CREATE", + "resourceType": "punches", + "resourceId": "01J2QC1M8TWXV5K2N9RB4D7PYF", + "payload": { "type": "OUT", "method": "GPS", "punchedAt": "2026-07-16T18:31:07.220Z", "lat": 25.197201, "lng": 55.274390, "accuracyM": 9.8, "isMock": false, "deviceId": "01HZX0K3T9RCB6W2P5M8Q4JD7E" } + }, + { + "opId": "01J2QC2P4VKXW9M3T6RD8B1NYC", + "opType": "CREATE", + "resourceType": "leaveRequests", + "resourceId": "01J2QC2P4VKXW9M3T6RD8B1NYC", + "payload": { "leaveTypeId": "01HVL3A9Q6XT2M8KRB5W7JD0PY", "startDate": "2026-09-01", "endDate": "2026-09-01", "startHalf": false, "endHalf": false, "reason": "Medical appointment" } + }, + { + "opId": "01J2QC3R7YWXK4V8N2TB6D9MPF", + "opType": "CREATE", + "resourceType": "punches", + "resourceId": "01J2QC3R7YWXK4V8N2TB6D9MPF", + "payload": { "type": "IN", "method": "GPS", "punchedAt": "2026-07-17T08:59:41.006Z", "lat": 24.991102, "lng": 55.146800, "accuracyM": 8.1, "isMock": false, "deviceId": "01HZX0K3T9RCB6W2P5M8Q4JD7E" } + } + ] +} +``` + +`200 OK` — the batch itself always succeeds; each op reports independently: + +```json +{ + "data": { + "results": [ + { "opId": "01J2QC1M8TWXV5K2N9RB4D7PYF", "status": "APPLIED", "resourceType": "punches", "resource": { "id": "01J2QC1M8TWXV5K2N9RB4D7PYF", "serverValidated": true, "insideFence": true, "updatedAt": "2026-07-17T12:00:04.118Z" } }, + { "opId": "01J2QC2P4VKXW9M3T6RD8B1NYC", "status": "REPLAYED", "resourceType": "leaveRequests", "resource": { "id": "01J2QC2P4VKXW9M3T6RD8B1NYC", "status": "PENDING", "days": 1.0, "updatedAt": "2026-07-17T07:44:51.930Z" } }, + { "opId": "01J2QC3R7YWXK4V8N2TB6D9MPF", "status": "REJECTED", "resourceType": "punches", + "problem": { "type": "https://api.worktrack.app/errors/geofence-violation", "title": "Punch outside geofence", "status": 422, "code": "GEOFENCE_VIOLATION", "detail": "Location is 18.4 km from nearest active geofence." } } + ] + }, + "meta": {} +} +``` + +Client contract per master spec §6.3: `APPLIED`/`REPLAYED` ⇒ outbox row `DONE`, local row reconciled (`syncStatus=SYNCED`, server fields win). `REJECTED` ⇒ outbox row `FAILED`, local row flagged, actionable notification raised — never silent loss. Ops for the same `resourceType` are applied in array order. + +### 3.5 `GET /sync/pull?types=attendanceDays,leaveRequests,notifications&cursor=eyJ3IjoiMjAyNi0wNy0xN1QwNzo0NDo1MS45MzBaIn0&limit=200` + +`200 OK`: + +```json +{ + "data": { + "changes": [ + { "type": "attendanceDays", "op": "UPSERT", + "doc": { "id": "01J2QCX0M4TWK8V2N7RB5D9PYA", "employeeId": "01HW8N4T2YV6RDK9Q1XB5MJ3PC", "date": "2026-07-16", "shiftId": "01HVJ2M8QK4XT6WB9RC3N5D0PZ", "firstInAt": "2026-07-16T08:57:02.310Z", "lastOutAt": "2026-07-16T18:31:07.220Z", "workedMinutes": 514, "breakMinutes": 60, "lateMinutes": 0, "earlyOutMinutes": 0, "overtimeMinutes": 34, "status": "PRESENT", "computedAt": "2026-07-17T12:00:05.402Z", "version": 3, "updatedAt": "2026-07-17T12:00:05.402Z" } }, + { "type": "leaveRequests", "op": "UPSERT", + "doc": { "id": "01J2QB7H5PWXK2M9V4TC8N1RDF", "status": "PENDING", "currentApproverId": "01HX9P4R2MKV7T3WBQ8C6JN0YD", "days": 2.5, "updatedAt": "2026-07-17T11:40:22.905Z" } }, + { "type": "notifications", "op": "UPSERT", + "doc": { "id": "01J2QD5T9WKXV3M8N4RB7C2PYE", "kind": "LEAVE_STEP_APPROVED", "title": "Leave request update", "body": "Step 1 of 2 approved", "dataJson": { "deepLink": "worktrack://leave/requests/01J2QB7H5PWXK2M9V4TC8N1RDF" }, "readAt": null, "sentAt": "2026-07-17T11:40:23.512Z", "updatedAt": "2026-07-17T11:40:23.512Z" } }, + { "type": "leaveRequests", "op": "TOMBSTONE", "id": "01J1XR8K2MTWV6N9B4C7D5PYQZ", "deletedAt": "2026-07-17T09:12:44.008Z" } + ] + }, + "meta": { "cursor": "eyJ3IjoiMjAyNi0wNy0xN1QxMjowMDowNS40MDJaIiwibiI6InMwN18wMUoyUUNYMCJ9", "hasMore": false } +} +``` + +Changes are ordered by `updatedAt` across the requested types; the cursor is a per-type watermark bundle. The client applies each page in one Room transaction, then persists `meta.cursor` into `sync_cursors`. `TOMBSTONE` deletes the local row. An expired cursor returns `VALIDATION_FAILED` with `errors[0].reason="CURSOR_EXPIRED"`; the client clears the cursor and performs a windowed re-pull (bounded by Room retention windows, `03-database-design.md` §6.1). + +### 3.6 `POST /payroll/runs` + +```json +{ + "id": "01J2QE8V2MKXT7W4N9RB3C6PYD", + "periodYear": 2026, + "periodMonth": 7, + "branchIds": ["01HV7B3M9QKX2T4WRC8N6JD1PZ", "01HV7B4N0RLY3U5XSD9P7KE2QA"] +} +``` + +`202 Accepted` — calculation dispatched to Cloud Tasks; poll `GET /payroll/runs` or await the notification: + +```json +{ + "data": { + "id": "01J2QE8V2MKXT7W4N9RB3C6PYD", + "companyId": "01HV5M2K8XQ4T9WBCJ6R3ZP0YA", + "periodYear": 2026, "periodMonth": 7, + "branchIdsJson": ["01HV7B3M9QKX2T4WRC8N6JD1PZ", "01HV7B4N0RLY3U5XSD9P7KE2QA"], + "status": "CALCULATING", + "startedBy": "01HXPAYADM4T7W2KRB9C3N6QYD", + "approvedBy": null, + "totalsJson": null, + "lockedAt": null, + "createdAt": "2026-07-17T13:05:10.660Z", + "updatedAt": "2026-07-17T13:05:10.660Z" + }, + "meta": {} +} +``` + +The job snapshots `EmployeeSalary` (effective-dated), `AttendanceDay`, and approved leave for the period; writes one `Payslip` + `PayslipLine`s per employee (`status=DRAFT`); fills `totalsJson`; transitions the run to `REVIEW`. `POST /payroll/runs/{id}/approve` then finalizes payslips and renders PDFs. `409 CONFLICT` if a non-`CLOSED` run overlaps the same period and any of the same branches. + +--- + +## 4. Sequence diagrams + +### 4.1 GPS punch validation + +```mermaid +sequenceDiagram + autonumber + participant App as Android App + participant API as API (Cloud Functions) + participant FS as Firestore + participant PS as Pub/Sub + + App->>App: Capture GPS fix + isMock check, mint ULID, write Room (syncStatus=PENDING) + OutboxEntry + App->>API: POST /attendance/punches (Idempotency-Key) + API->>API: Verify ID token -> claims {cid,r,b,eid} + API->>API: RBAC attendance:punch, device binding + Play Integrity gate + API->>FS: Load active geofences (branch), last punch (deviceId) + API->>API: Haversine vs fences, accuracy gate, speed-of-travel plausibility, IN/OUT debounce + alt valid + API->>FS: Write punch (serverValidated=true, insideFence, geofenceId) + API->>PS: Publish day-recompute {employeeId, date} (ordering key = employeeId) + API-->>App: 201 AttendancePunch + PS->>FS: (async, debounced 30s) recompute AttendanceDay, version++ + else geofence violation (strict policy) + API-->>App: 422 problem+json code=GEOFENCE_VIOLATION + App->>App: Outbox FAILED + actionable notification (suggest regularization) + end +``` + +### 4.2 QR kiosk TOTP flow + +```mermaid +sequenceDiagram + autonumber + participant Kiosk as Kiosk Terminal (KIOSK role) + participant Emp as Employee App + participant API as API + participant FS as Firestore + + Kiosk->>Kiosk: Every 30s: window=floor(now/30), sig=HMAC-SHA256(kioskSecret, kioskId+"."+window) + Kiosk->>Kiosk: Render QR = "v1..." + Emp->>Kiosk: Scan QR (ML Kit) + Emp->>API: POST /attendance/punches {method:QR, kioskToken, deviceId} (Idempotency-Key) + API->>API: Verify token, RBAC, device binding gate + API->>FS: Load kiosk device + secret by kioskId + API->>API: Recompute HMAC, check sig + window skew <= +/-1 (90s grace) + API->>API: Kiosk branch == employee branch? + alt token valid + API->>FS: Write punch (method=QR, kioskId, serverValidated=true) + API-->>Emp: 201 AttendancePunch + else invalid signature / stale window / branch mismatch + API-->>Emp: 422 problem+json code=KIOSK_TOKEN_INVALID + Emp->>Emp: Prompt rescan (fresh window) + end +``` + +### 4.3 Leave approval chain + +```mermaid +sequenceDiagram + autonumber + participant Emp as Employee App + participant API as API + participant FS as Firestore + participant TL as Team Lead + participant BM as Branch Manager + + Emp->>API: POST /leave/requests + API->>FS: Load policy, balance, holidays; compute days + API->>FS: TXN: create request (PENDING, chain[TL,BM]), balance.pendingDays += days (version check) + API-->>Emp: 201 LeaveRequest (currentApproverId=TL) + API->>TL: NotificationMessage (deep link worktrack://approvals) + TL->>API: POST /leave/requests/{id}/decide {APPROVE} + API->>FS: Update chain step 1, currentApproverId=BM + API->>BM: NotificationMessage + BM->>API: POST /leave/requests/{id}/decide {APPROVE} + API->>FS: TXN: status=APPROVED, decidedAt; balance.pendingDays -= days, usedDays += days (version check); AttendanceDay(range).status=LEAVE + API->>Emp: NotificationMessage LEAVE_DECIDED + Note over API,FS: Any REJECT is terminal - status=REJECTED, pendingDays released, employee notified +``` + +### 4.4 Offline sync push/pull cycle + +```mermaid +sequenceDiagram + autonumber + participant UI as Compose UI + participant Room as Room (source of truth) + participant SW as SyncWorker (WorkManager) + participant API as API + + UI->>Room: Mutation written optimistically (syncStatus=PENDING) + OutboxEntry(idempotencyKey) + Note over SW: Network-constrained, exponential backoff, unique work + SW->>Room: Drain outbox FIFO per resourceType (state=PENDING -> IN_FLIGHT) + SW->>API: POST /sync/push {ops[<=100]} + API-->>SW: 200 per-op results (APPLIED | REPLAYED | REJECTED+problem) + SW->>Room: DONE + reconcile (server fields win) / FAILED + notification + loop per resourceType cursor + SW->>API: GET /sync/pull?types&cursor + API-->>SW: 200 {changes[UPSERT|TOMBSTONE], meta.cursor, hasMore} + SW->>Room: Apply page in one TXN, advance sync_cursors row + end + Room-->>UI: Flow emissions re-render state +``` + +--- + +## 5. Webhooks (P4 — design sketch) + +Outbound webhooks ship with the open-API program in P4 (master spec §8). Design is fixed now so P0–P3 event producers emit compatible internal events. + +### 5.1 Event catalog + +Event names are `resource.action`, versioned by payload schema (`specversion` per event): + +| Event | Fired when | Payload core | +|---|---|---| +| `employee.created` / `employee.updated` / `employee.deactivated` | Org lifecycle | Employee (PII-minimized: id, employeeCode, org placement, status) | +| `attendance.punch.recorded` | Punch persisted (valid or not) | Punch incl. `serverValidated`, `invalidReason` | +| `attendance.day.computed` | AttendanceDay (re)computed | AttendanceDay | +| `attendance.regularization.decided` | Terminal decision | RegularizationRequest | +| `leave.request.submitted` / `leave.request.decided` / `leave.request.cancelled` | Leave lifecycle | LeaveRequest + delta of balance effect | +| `shift.swap.decided` | Swap approved/rejected | ShiftSwapRequest + affected assignments | +| `payroll.run.status_changed` | Any run transition (`CALCULATING→REVIEW→APPROVED→PAID→CLOSED`) | PayrollRun (totalsJson included from REVIEW) | +| `payslip.finalized` | Payslip goes FINAL | Payslip (no lines; fetch via API) | +| `announcement.published` | `publishAt` reached | Announcement | + +Delivery: per-tenant endpoint registrations with per-event subscriptions; at-least-once via Cloud Tasks with exponential backoff (max 24h, then dead-letter + admin notification); consumers must be idempotent on `eventId` (ULID). + +### 5.2 Envelope and signature + +```json +{ + "eventId": "01JABCXYZ0M4TWK8V2N7RB5DQP", + "event": "leave.request.decided", + "specversion": "1.0", + "companyId": "01HV5M2K8XQ4T9WBCJ6R3ZP0YA", + "occurredAt": "2026-07-17T11:58:00.412Z", + "data": { … } +} +``` + +Headers: + +``` +X-WorkTrack-Event: leave.request.decided +X-WorkTrack-Delivery: 01JABD0FQ2… (unique per attempt) +X-WorkTrack-Timestamp: 1784721480 (unix seconds, signing time) +X-WorkTrack-Signature: v1=hex(HMAC-SHA256(endpointSecret, timestamp + "." + rawBody)) +``` + +Verification rules for consumers: (1) recompute the HMAC over the **raw** body with the shared `endpointSecret` (issued at registration, rotatable with dual-signing overlap `v1=…,v1=…`); (2) constant-time compare; (3) reject if `|now − timestamp| > 300s` (replay protection); (4) dedupe on `eventId`. Failed signature or stale timestamp must return 4xx so the delivery is not retried against a misconfigured secret indefinitely; WorkTrack alerts the tenant admin after 10 consecutive signature failures. diff --git a/docs/05-android-architecture.md b/docs/05-android-architecture.md new file mode 100644 index 0000000..31969a8 --- /dev/null +++ b/docs/05-android-architecture.md @@ -0,0 +1,307 @@ +# WorkTrack — Android App Architecture & Navigation + +Version: 1.0 · Status: Approved · Derives from: `00-master-spec.md` (§2, §6) + +**Purpose.** This document specifies the Android application architecture for WorkTrack: the Clean Architecture layering and Gradle module graph, the convention-plugin build system, the MVVM/UDF presentation contract, the complete navigation design (routes, arguments, deep links, role gating, state preservation), offline-first behavior per screen, runtime permission handling with the Play Integrity integration point, and the testing strategy. It is binding for all Android code in this repository; deviations require an update to this document and, where applicable, to the master spec first. + +--- + +## 1. Architectural principles + +1. **Clean Architecture, dependency rule inward.** UI depends on domain; domain depends on nothing Android-specific; data implements domain contracts. No feature module ever touches Room, Retrofit, or DataStore directly. +2. **Offline-first.** Room is the single local source of truth (master spec §6.3). Every screen renders from Room `Flow`s; the network only feeds Room via sync, never the UI directly. +3. **Unidirectional data flow (UDF).** State flows down as a single immutable `UiState`; events flow up as a sealed `UiEvent`; one-shot effects are delivered exactly once. +4. **Server-authoritative money paths.** Attendance validity, leave balances, and payslips are read-only projections on the client; the app proposes, the server decides (master spec §3). +5. **Composable isolation.** Screens are stateless; all state hoisting terminates at the ViewModel. This makes every screen previewable, screenshot-testable, and reusable in kiosk mode (P1). + +### 1.1 Layering + +| Layer | Modules | Contents | Allowed dependencies | +|---|---|---|---| +| Feature (UI) | `feature:auth`, `feature:dashboard`, `feature:attendance`, `feature:leave`, `feature:payslips`, `feature:profile` | Compose screens, ViewModels, per-feature nav graphs | `core:domain`, `core:designsystem`, `core:common` | +| Domain | `core:domain` | Use cases, repository **interfaces**, domain policies (e.g. punch eligibility) | `core:model`, `core:common` | +| Data | `core:data` | Repository implementations, mappers, offline write pipeline (Room + outbox enqueue) | `core:database`, `core:network`, `core:datastore`, `core:domain`, `core:model` | +| Data sources | `core:database` (Room), `core:network` (Retrofit/OkHttp), `core:datastore` (Proto DataStore) | DAOs/entities, API services/DTOs, preferences | `core:model`, `core:common` | +| Sync | `core:sync` | WorkManager workers, outbox processor, cursor pull, scheduling | `core:data` | +| Cross-cutting | `core:model` (entities/value types), `core:common` (`Result`, dispatchers, time/Clock abstraction), `core:designsystem` (M3 theme + components) | — | `core:model` → nothing; `core:common` → nothing | + +`app` composes everything: root `NavHost`, main scaffold, Hilt application, WorkManager initialization, deep-link intent filters. + +## 2. Gradle module graph + +Exactly the graph from master spec §6.1: + +```mermaid +graph TD + app --> fauth[feature:auth] + app --> fdash[feature:dashboard] + app --> fatt[feature:attendance] + app --> fleave[feature:leave] + app --> fpay[feature:payslips] + app --> fprof[feature:profile] + app --> sync[core:sync] + app --> data[core:data] + + fauth --> domain[core:domain] + fdash --> domain + fatt --> domain + fleave --> domain + fpay --> domain + fprof --> domain + fauth --> ds[core:designsystem] + fdash --> ds + fatt --> ds + fleave --> ds + fpay --> ds + fprof --> ds + fauth --> common[core:common] + fdash --> common + fatt --> common + fleave --> common + fpay --> common + fprof --> common + + sync --> data + data --> db[core:database] + data --> net[core:network] + data --> dstore[core:datastore] + data --> domain + data --> model[core:model] + domain --> model + domain --> common + db --> model + db --> common + net --> model + net --> common + dstore --> model + dstore --> common +``` + +Rules enforced in CI (dependency-guard / `checkModuleGraph` task): + +- `feature:*` may not depend on `core:data`, `core:database`, `core:network`, `core:datastore`, `core:sync`, or another `feature:*`. +- `core:domain` has zero Android framework dependencies (pure Kotlin/JVM module; `SavedStateHandle` and `Flow` types come from KMP-safe artifacts only). +- Only `app` depends on `core:sync`; features trigger sync through the `SyncRequester` interface in `core:domain`, implemented in `core:sync` and bound in `app`. +- `core:designsystem` contains no business logic and no ViewModels. + +## 3. Build logic — convention plugins + +All build configuration lives in `build-logic/` as composite-build convention plugins (master spec §6.1): + +| Plugin id | Applies to | Provides | +|---|---|---| +| `worktrack.android.application` | `app` | AGP application config, SDK levels (min 26 / target latest stable), signing config plumbing, R8 rules, build types (`debug`, `benchmark`, `release`) | +| `worktrack.android.library` | all `core:*` Android modules | AGP library config, Kotlin 2.x compiler options (`-Xjvm-default=all`, explicit API mode for `core:domain`/`core:model`), lint baseline | +| `worktrack.android.library.compose` | `core:designsystem`, any library with UI | Compose compiler wiring, compose BOM, metrics/reports flags | +| `worktrack.android.feature` | all `feature:*` | = library + compose + hilt + default deps on `core:domain`, `core:designsystem`, `core:common`, navigation-compose, lifecycle | +| `worktrack.android.hilt` | any module with DI | Hilt + KSP wiring | +| `worktrack.android.room` | `core:database` | Room + KSP, schema export dir (`schemas/`, checked in for migration tests) | + +Why convention plugins rather than `subprojects {}` blocks or shared `.gradle` scripts: + +1. **Single point of change.** SDK bump, Kotlin upgrade, or a new lint rule is one edit in `build-logic`, not 16 build files. +2. **Type-safe and testable.** Plugins are Kotlin classes; misconfiguration fails compilation of `build-logic`, not a runtime surprise mid-build. +3. **Feature-module cost is near zero.** A new feature's `build.gradle.kts` is ~5 lines (`id("worktrack.android.feature")` + one namespace), which keeps the module graph honest — nobody skips modularization because setup is tedious. +4. **Configuration-cache and build-scan friendly.** No cross-project configuration; every module is isolated, enabling parallel configuration and remote build cache hits. + +Versions are centralized in `gradle/libs.versions.toml`; convention plugins read the catalog, so modules never declare raw coordinates. + +## 4. Presentation contract (MVVM + UDF) + +Every screen follows one contract, with no exceptions: + +```kotlin +// 1. Single immutable state — the only thing the screen renders. +data class LeaveApplyUiState( + val leaveTypes: List = emptyList(), + val balances: Map = emptyMap(), + val form: LeaveFormUi = LeaveFormUi(), + val submitInProgress: Boolean = false, + val isOffline: Boolean = false, + val error: UiText? = null, +) + +// 2. Sealed events — the only way the screen talks to the ViewModel. +sealed interface LeaveApplyEvent { + data class TypeSelected(val leaveTypeId: String) : LeaveApplyEvent + data class DatesChanged(val start: LocalDate, val end: LocalDate) : LeaveApplyEvent + data object Submit : LeaveApplyEvent +} + +// 3. One-shot effects — navigation, snackbars, system dialogs. +sealed interface LeaveApplyEffect { + data class NavigateToDetail(val requestId: String) : LeaveApplyEffect + data class ShowSnackbar(val message: UiText) : LeaveApplyEffect +} + +@HiltViewModel +class LeaveApplyViewModel @Inject constructor( + private val applyLeave: ApplyLeaveUseCase, + observeLeaveTypes: ObserveLeaveTypesUseCase, + observeBalances: ObserveLeaveBalancesUseCase, + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + val uiState: StateFlow = /* combine(...).stateIn( + viewModelScope, SharingStarted.WhileSubscribed(5_000), LeaveApplyUiState()) */ + private val _effects = Channel(Channel.BUFFERED) + val effects: Flow = _effects.receiveAsFlow() + fun onEvent(event: LeaveApplyEvent) { /* ... */ } +} +``` + +Contract rules: + +- **One `StateFlow` per ViewModel.** No secondary `LiveData`, no exposed `MutableStateFlow`, no per-field flows. `stateIn(WhileSubscribed(5_000))` so upstream Room flows stop when the screen leaves composition (survives rotation without restart). +- **Effects via `Channel(BUFFERED).receiveAsFlow()`**, collected in the screen with `LaunchedEffect` + `repeatOnLifecycle(STARTED)`. Effects are for things that must happen exactly once (navigate, snackbar, permission launch). Anything renderable belongs in `UiState` instead. +- **Screens are stateless composables**: `LeaveApplyScreen(state: LeaveApplyUiState, onEvent: (LeaveApplyEvent) -> Unit)`. A thin `LeaveApplyRoute` composable owns the ViewModel, collects state with `collectAsStateWithLifecycle()`, and wires effects to the `NavController`/`SnackbarHostState`. Only `*Route` composables may reference a ViewModel. +- **Form/transient input survives process death** via `SavedStateHandle` (see §5.5); domain data never does — it re-materializes from Room. +- **Loading is modeled, not implied.** `UiState` uses explicit sub-states (`isOffline`, `submitInProgress`, `error: UiText?`); no screen infers loading from null. +- **`UiText`** wraps string resources vs. raw server strings so composables stay context-free and testable. + +Use cases in `core:domain` are single-verb classes (`ApplyLeaveUseCase`, `RecordPunchUseCase`, `ObserveAttendanceDaysUseCase`) with `operator fun invoke`. Commands return `Result` from `core:common`; observations return `Flow`. ViewModels never call repositories directly. + +## 5. Navigation + +Root structure per master spec §6.2: `AuthGraph` (Login → ForgotPassword → DeviceBinding) → `MainGraph` with a bottom-bar scaffold (**Dashboard**, **Attendance**, **Leave**, **Profile**) and nested destinations. + +### 5.1 Route table + +Routes are defined as type-safe `@Serializable` destinations (Navigation-Compose 2.8+); the patterns below are the canonical string forms and deep-link URIs. + +| Route pattern | Args | Deep link | Entry points | Role gating | +|---|---|---|---|---| +| `auth/login` | — | — | App start (unauthenticated) | none | +| `auth/forgot-password` | — | — | Login | none | +| `auth/device-binding` | — | — | Post-login when no bound `Device` for this install | authenticated, pre-main | +| `main/dashboard` | — | — | Bottom bar (start destination) | any authenticated | +| `main/attendance` | — | — | Bottom bar; dashboard punch card | any authenticated | +| `main/attendance/history?from={date}&to={date}` | `from`, `to` optional ISO dates | — | Attendance hub; dashboard "this week" card | self only | +| `main/attendance/punch?method={method}` | `method ∈ {GPS, QR}` (FACE in P1) | — | Attendance hub CTA; dashboard quick action | `attendance:punch` (all employees) | +| `main/leave` | — | — | Bottom bar | any authenticated | +| `main/leave/apply` | — | — | Leave hub CTA | `leave:request` | +| `main/leave/requests/{requestId}` | `requestId` (ULID) | `worktrack://leave/requests/{id}` | Leave list; push notification; approvals inbox | self, or approver on the request's chain | +| `main/approvals` | — | `worktrack://approvals` | Dashboard badge card; push notification | any of `TEAM_LEAD`, `BRANCH_MANAGER`, `HR_ADMIN`, `COMPANY_ADMIN` (client mirror of `leave:approve` / `attendance:approve`) | +| `main/payslips` | — | — | Profile section; dashboard card | `payroll:read-self` | +| `main/payslips/{payslipId}` | `payslipId` (ULID) | `worktrack://payslips/{id}` | Payslip list; push notification | owner of the payslip | +| `main/announcements` | — | — | Dashboard feed "see all"; notification | any authenticated | +| `main/profile` | — | — | Bottom bar | any authenticated | +| `main/settings` | — | — | Profile top-bar action | any authenticated | + +Deep-link handling: `app` declares the `worktrack://` scheme intent filter. On cold start, `MainActivity` hands the intent to the `NavHost`; if the session is invalid the pending destination is stored in `SavedStateHandle` of the auth flow and replayed after login + device binding. Role-gated deep links (e.g. `worktrack://approvals` sent to an `EMPLOYEE` whose lead role was revoked) resolve to Dashboard with an explanatory snackbar — the server remains the enforcement point; client gating is UX only (master spec §1.1). + +### 5.2 Nav graph + +```mermaid +flowchart TD + subgraph AuthGraph + Login[auth/login] --> Forgot[auth/forgot-password] + Login -->|"authenticated, unbound device"| Bind[auth/device-binding] + end + Bind -->|"bound (POST /devices ok)"| Dash + Login -->|"authenticated + bound"| Dash + + subgraph MainGraph [MainGraph — bottom-bar scaffold] + Dash[main/dashboard] + Att[main/attendance] + Leave[main/leave] + Prof[main/profile] + + Att --> Hist[attendance/history] + Att --> Punch["attendance/punch (GPS/QR)"] + Leave --> Apply[leave/apply] + Leave --> LDetail["leave/requests/{id}"] + Dash --> Appr[approvals inbox] + Appr --> LDetail + Prof --> Pay[payslips] + Pay --> PDetail["payslips/{id}"] + Dash --> Ann[announcements] + Prof --> Set[settings] + end + + Prof -->|"logout / revoked"| Login +``` + +`AuthGraph` and `MainGraph` are separate nested graphs on the root `NavHost`. Successful auth executes `navigate(MainGraph) { popUpTo(AuthGraph) { inclusive = true } }` so back never returns to Login. Session revocation (401 with terminal reason from `GET /me`, or Firebase token revoked) clears Room user-scoped tables, cancels sync work, and pops to `AuthGraph` the same way in reverse. + +### 5.3 Bottom bar behavior + +- Visible only for the four top-level destinations (`dashboard`, `attendance`, `leave`, `profile`); hidden on all nested destinations (punch flow, detail screens) via `currentBackStackEntryAsState()` route matching. +- Tab switch uses the standard M3 pattern: `navigate(tab) { popUpTo(navController.graph.findStartDestination().id) { saveState = true }; launchSingleTop = true; restoreState = true }` — each tab keeps an independent back stack; re-selecting the current tab pops that tab's stack to its root. +- Approvals inbox is **not** a tab; it is reached from the Dashboard approvals card (badge shows pending count from Room) and via deep link, keeping the bar identical for all roles. +- System back on a tab root (other than Dashboard) returns to Dashboard; back on Dashboard exits the app. + +### 5.4 State preservation + +- Tab back stacks: `saveState`/`restoreState` as above; Compose `rememberSaveable` preserves scroll positions (`LazyListState`) and expanded/collapsed UI within stops. +- ViewModels use `SharingStarted.WhileSubscribed(5_000)` so configuration changes never re-trigger loads; Room flows re-attach instantly with the last cached emission. + +### 5.5 Process death (SavedStateHandle) + +| Concern | Mechanism | +|---|---| +| Current destination + back stacks | Navigation-Compose saves the nav state to the Activity's saved instance state automatically | +| In-progress form input (leave apply dates/reason, regularization note, search queries) | ViewModel writes each field to `SavedStateHandle` keys on change; state builder reads `savedStateHandle.getStateFlow(key, default)` and combines it with Room flows | +| In-flight punch | Never held in memory only: `RecordPunchUseCase` writes Room + `OutboxEntry` transactionally *before* any UI acknowledgment, so process death after tap loses nothing (see doc `08-sync-strategy.md` §3) | +| Pending deep link during auth | Stored in `SavedStateHandle` of `AuthGraph`'s shared back-stack entry, replayed post-binding | +| Domain data | Never saved to instance state — re-materializes from Room; instance state stays under the transaction size budget | + +## 6. Offline-first behavior per screen + +Master spec §6.3 governs; per-screen specifics: + +| Screen | Renders from Room | Requires network | Offline mutation pattern | +|---|---|---|---| +| Dashboard | Today's `AttendanceDay`, own punches, `LeaveBalance`, latest `Announcement`s, pending approvals count | No — fully cached; freshness label shows `lastSyncedAt` when stale > 15 min | n/a (read-only) | +| Punch — GPS | Shift context (`ShiftAssignment`), geofences for the employee's branch | No for capture; GPS fix is local. `insideFence` computed on-device against cached `Geofence` rows | **Optimistic punch**: insert `AttendancePunch(serverValidated=false)` + outbox entry in one Room transaction; UI confirms immediately with "Recorded — will verify when online" chip; `serverValidated`/`invalidReason` reconcile on push ack. Punches are append-only: no local edit/delete ever | +| Punch — QR kiosk | Kiosk scan UX | **Yes** (soft requirement): the TOTP QR window is 30 s, so validation is near-real-time; offline QR punches are still queued, and the server accepts tokens within a bounded clock-skew grace, else rejects with actionable notification | Same optimistic insert; higher rejection probability is surfaced up front ("QR punches need connectivity soon") | +| Attendance history | `AttendanceDay` + punches for range | No; pull-to-refresh triggers expedited sync | Regularization request (P1) follows the leave-apply pattern | +| Leave hub / balances | `LeaveType`, `LeaveBalance`, own `LeaveRequest`s | No | — | +| Leave apply | Types, balances, holiday calendar for date validation | No to submit | **Optimistic apply**: insert `LeaveRequest(status=PENDING, syncStatus=PENDING)` + outbox entry; balance shows a local `pendingDays` overlay clearly marked "pending sync"; server rejection (e.g. stale balance) flips the row to `REJECTED` with reason and raises a notification — never silent (master spec §6.3.6) | +| Leave detail | Request row + approval chain JSON | No | Cancel = optimistic status change + outbox op | +| Approvals inbox | Pending `LeaveRequest`/`RegularizationRequest` where user is `currentApproverId` | No to view; decisions queue offline | Decide = optimistic status + outbox `decide` op; conflicting decision (someone else decided first) is server-rejected and reconciled with a notification | +| Payslips list/detail | `Payslip` + `PayslipLine` rows | PDF download (`pdfUrl`) requires network; cached after first fetch | n/a — payslips are server-authoritative, read-only | +| Announcements | `Announcement` rows | No | Read receipts queue via outbox (`POST /notifications/{id}/read`) | +| Profile / settings | `Employee` row, bound `Device` | Avatar upload requires network | Editable profile fields: optimistic Room update + outbox; last-write-wins on the server for these fields (doc 08 §6) | +| Auth / device binding | — | **Yes** — Firebase Auth and `POST /devices` are online-only by design | n/a | + +Global rules: + +- A persistent, non-blocking offline indicator (top of scaffold) appears when connectivity is lost; screens never block on it. +- `syncStatus` renders as a subtle per-row glyph (pending ⟳ / failed ⚠) on user-owned mutable rows; tapping a failed row shows the error and a retry action. +- No screen issues a direct network call for domain data. The only non-sync network calls are Firebase Auth, device binding, file transfers (avatar, payslip PDF, leave attachment), and Play Integrity. + +## 7. Permissions & Play Integrity + +### 7.1 Runtime permissions + +| Permission | Feature | Strategy | +|---|---|---| +| `ACCESS_FINE_LOCATION` (+ `ACCESS_COARSE_LOCATION` fallback) | GPS punch, geofence check | Requested **in-context** on first GPS punch attempt, never at onboarding. Pre-request rationale sheet explains: location is captured only at the moment of punching, never tracked in background. If the user selects "approximate only" (Android 12+), the punch flow explains that fine accuracy is required for geofence validation and offers the settings shortcut; a coarse-only punch is still recorded but flagged (`accuracyM` high) for server-side review rather than blocked. Permanent denial → punch method selector hides GPS with an inline explanation and offers QR | +| `CAMERA` | QR kiosk scan; face verification capture (P1) | Requested when the user opens the QR scanner. Rationale: "camera is used only to scan the kiosk code / verify it's you; images are processed on-device" (face capture handling per doc `07-security-architecture.md` §6.6) | +| `POST_NOTIFICATIONS` (API 33+) | Approvals, sync rejection alerts, announcements | Requested after first successful login, from a dismissible dashboard card explaining what notifications carry. Denial degrades to in-app notification center only (`GET /notifications` data still syncs) | + +Implementation: a single `PermissionGate` composable in `core:designsystem` renders rationale → system dialog → denial fallback as a state machine, driven by an effect from the ViewModel (`RequestPermission` effect) so permission flows stay testable. No background location is ever requested; the manifest never includes `ACCESS_BACKGROUND_LOCATION`. + +### 7.2 Play Integrity integration point + +- `IntegrityTokenProvider` (interface in `core:domain`, implementation in `core:data` wrapping the Play Integrity **standard request** API) warms up a token provider at app start and produces a token on demand. +- `RecordPunchUseCase` requires an integrity token: the token (or a structured `UNAVAILABLE` marker with reason) is stored on the `OutboxEntry` payload for the punch and sent to `POST /attendance/punches`; the server decodes the verdict and persists it on the `Device`/punch (master spec §7). Tokens are bound to a server-issued nonce fetched during device binding and rotated on each sync session to prevent replay. +- Device binding (`POST /devices`) sends the first integrity verdict; the server may refuse binding on `MEETS_NO_INTEGRITY`. Client behavior on failure is defined in doc `07-security-architecture.md` §6.2 — the app degrades to "punch recorded, subject to review", never hard-crashes on Integrity API unavailability (e.g. no Play Services). +- Mock-location detection: `Location.isMock` (API 31+; `isFromMockProvider` before) is captured per GPS fix and transmitted with the punch payload; detection is advisory client-side, enforced server-side. + +## 8. Testing strategy + +| Level | Scope | Tooling | Gate | +|---|---|---|---| +| Unit — domain | Use cases, policies (punch eligibility, leave day counting incl. half-days/holidays) | JUnit5, kotlinx-coroutines-test, fake repositories | PR-blocking; ≥ 90% line coverage in `core:domain` | +| Unit — ViewModels | State reduction, event handling, effect emission | **Turbine** for `uiState`/`effects` flows; `MainDispatcherRule`; `SavedStateHandle` restoration cases (create VM with pre-seeded handle, assert form state) | PR-blocking | +| Room DAO | Every DAO query, migrations | `Room.inMemoryDatabaseBuilder` under Robolectric for query tests; `MigrationTestHelper` against checked-in `schemas/` for every schema bump; FIFO ordering + transaction atomicity tests for outbox DAO | PR-blocking; a schema change without a migration test fails CI | +| Screenshot | Every `core:designsystem` component and each feature screen's canonical states (loading/empty/error/content, light/dark, small+large font scale, en + one RTL locale) | **Paparazzi** (JVM, no emulator); golden images checked in; `verifyPaparazzi` in CI, `recordPaparazzi` to update with review | PR-blocking on pixel diff | +| Sync end-to-end | Full outbox → push → pull → reconcile loop | JVM integration tests in `core:sync`: real Room (in-memory) + real OkHttp against **MockWebServer scripted as a fake WorkTrack server** (idempotency-key replay returns same result; 409 conflict; 422 rejection; 500 then success for backoff). Scenarios: offline punch burst then reconnect; duplicate delivery; leave apply rejected on stale balance surfaces notification; cursor resume after crash mid-pull | PR-blocking | +| Instrumented smoke | Auth → bind → punch → leave apply happy path on emulator | Compose UI tests + Hilt test app, `TestDispatcher`-driven WorkManager (`WorkManagerTestInitHelper`) | Nightly + release-blocking | + +Cross-cutting conventions: + +- Fakes over mocks for repositories and data sources (fakes live beside the interfaces in `testFixtures`); Mockito/MockK only for platform seams (Integrity, location client). +- Deterministic time everywhere via `core:common`'s `Clock` abstraction — no `System.currentTimeMillis()` outside `core:common`. +- Flaky-test policy: a test that flakes twice in a week is quarantined with an owning ticket; quarantine list must be empty for a release branch cut. diff --git a/docs/06-web-admin-design.md b/docs/06-web-admin-design.md new file mode 100644 index 0000000..a9559ef --- /dev/null +++ b/docs/06-web-admin-design.md @@ -0,0 +1,297 @@ +# WorkTrack — Web Admin Console Design + +Version: 1.0 · Status: Approved · Derives from: `00-master-spec.md` (§1.1, §2, §5, §8 Phase P3) · Companion: `07-security-architecture.md` + +**Purpose.** This document specifies the WorkTrack Web Admin Console: a React 18 + TypeScript single-page application served from Firebase Hosting that consumes the same versioned REST API (`https://api.worktrack.app/v1`) as the Android app. It defines the information architecture and role-based navigation for admin personas, screen-by-screen functional specs, and the frontend engineering standards — state management, RBAC-driven UI gating, large-table virtualization, optimistic update policy, accessibility, and internationalization. Implementation is roadmap Phase P3; this design is final and binding for that phase. + +--- + +## 1. Platform and stack + +| Concern | Decision | +|---|---| +| Framework | React 18 + TypeScript (strict), Vite build, SPA with client-side routing (React Router) | +| Hosting | Firebase Hosting; `/**` rewrite to `index.html`; immutable hashed assets; API is **not** proxied — the SPA calls `https://api.worktrack.app/v1` directly with CORS | +| Identity | Firebase Auth Web SDK (same tenant claims `{ cid, r, b, eid }` as Android); ID token attached as `Authorization: Bearer` by a fetch wrapper that refreshes via the SDK before expiry | +| Server state | TanStack Query v5 (see §5) | +| URL state | Route params + search params as the single source of truth for filters, pagination cursors, selected entities, wizard steps | +| Client state | Minimal: a small Zustand store for session context (claims, permission set, feature flags) and UI chrome (sidebar collapsed, density); everything else is server or URL state | +| Design system | WorkTrack Web DS: token-compatible with the Android M3 theme (same color roles, type ramp, spacing scale); components built on Radix primitives for accessibility | +| Errors | RFC 7807 `problem+json` parsed centrally; `type` mapped to user-facing messages and remediation hints | +| Testing | Vitest + React Testing Library (components), MSW fake API (integration), Playwright (E2E per persona), axe-core in CI | + +The SPA is **online-only** (admin workflows are connectivity-assumed); TanStack Query caching provides resilience to transient failures, but there is no outbox/offline mode — that is an Android-only contract (master spec §6.3). + +## 2. Personas and information architecture + +Admin console personas (master spec §1.1): `COMPANY_ADMIN`, `HR_ADMIN`, `PAYROLL_ADMIN`, `BRANCH_MANAGER`, `AUDITOR`. (`SUPER_ADMIN` uses an internal ops console outside this document; `EMPLOYEE`/`TEAM_LEAD`/`KIOSK` do not sign in here — the console rejects sessions holding none of the admin roles.) + +### 2.1 Sidebar navigation tree + +``` +Dashboard +Org + ├── Branches + ├── Departments + └── Positions +Employees + ├── Directory + └── Onboarding (P2: checklists) +Attendance + ├── Live Board + ├── Exceptions Queue + └── Regularizations +Rosters + ├── Planner + └── Swap Requests +Leave + ├── Approvals + ├── Requests + └── Balances +Payroll + ├── Runs + ├── Payslips + └── Salary Structures +Announcements +Documents +Audit +Settings + ├── Company Profile + ├── Branches & Geofences + ├── Shifts + ├── Leave Policies + ├── Salary Components + ├── Holiday Calendars + └── Roles & Permissions +``` + +### 2.2 Role → navigation visibility matrix + +Visibility mirrors the server permission catalog (`07-security-architecture.md` §4); the sidebar renders only sections for which the session holds at least one required permission. ✔ = full, ◐ = scoped/partial, — = hidden. + +| Section | COMPANY_ADMIN | HR_ADMIN | PAYROLL_ADMIN | BRANCH_MANAGER | AUDITOR | +|---|---|---|---|---|---| +| Dashboard | ✔ | ✔ | ✔ (payroll KPIs) | ◐ own branches | ✔ read-only | +| Org | ✔ | ✔ | — | ◐ read own branches | ✔ read-only | +| Employees | ✔ | ✔ | ◐ read + salary tab | ◐ own branches, no salary | ✔ read-only, no salary | +| Attendance | ✔ | ✔ | ◐ read (payroll inputs) | ◐ own branches | ✔ read-only | +| Rosters | ✔ | ✔ | — | ◐ own branches (primary user) | ✔ read-only | +| Leave | ✔ | ✔ | ◐ read (LOP inputs) | ◐ own branches | ✔ read-only | +| Payroll | ✔ | ◐ inputs only, no approve | ✔ | — | ✔ read-only | +| Announcements | ✔ | ✔ | — | ◐ own-branch audience | ✔ read-only | +| Documents | ✔ | ✔ | — | ◐ own branches | ✔ read-only | +| Audit | ✔ | ◐ own actions area | ◐ payroll resources | — | ✔ (primary user) | +| Settings | ✔ | ◐ leave policies, holidays | ◐ salary components | — | ✔ read-only | + +`BRANCH_MANAGER` scoping: every list/query the console issues for a branch-scoped session carries `branchId` filters constrained to the `b` claim; the server re-enforces regardless (scope narrowing, `07-security-architecture.md` §4.3). `AUDITOR` sees read-only variants of every screen: all mutating controls are removed (not merely disabled), and export actions are audit-logged. + +### 2.3 Global chrome + +- **Top bar**: company switcher (only for users holding roles in multiple companies — re-authenticates to swap `cid` claims), global search (employees by name/code — `GET /employees?q=`), notification bell (`GET /notifications`), session menu. +- **Breadcrumbs** on every screen below the top level; entity IDs in breadcrumbs are copyable ULIDs. +- **Environment banner** on non-production origins. + +## 3. Screen specifications + +Every screen defines the four canonical states. Unless overridden below: **Loading** = skeleton matching final layout (no spinners for > 300 ms content, no layout shift); **Empty** = illustration + one-line explanation + primary CTA (hidden if the user lacks the CTA permission); **Error** = inline problem card with `problem+json` title, correlation id, and Retry (refetch); table row-level failures never blank the whole screen. + +### 3.1 Analytics dashboard (`/dashboard`) + +- **Purpose**: at-a-glance workforce health for the persona's scope; entry point to exceptions needing action. +- **Data**: `GET /analytics/kpis?scope&period`, `GET /analytics/insights`, pending counts from `GET /leave/requests?status=PENDING&limit=1` (meta count) and attendance exceptions. +- **Components**: KPI stat row (headcount, present today, absent, on leave, late %, pending approvals, payroll days-to-cutoff); trend charts (attendance % 30d, overtime minutes by branch, leave consumption vs accrual); AI insights panel (absenteeism risk, overtime anomaly, attrition signals — each card links to the filtered underlying list and carries a "why am I seeing this" explainer); action queue (top 5 approvals inline-decidable). +- **Primary actions**: period selector (URL param `?period=`), scope selector (company/branch — gated by role), drill-through to filtered screens. +- **States**: per-widget loading/error isolation (one failed widget shows a compact retry card, the rest render); empty insights = "No anomalies detected for this period". + +### 3.2 Employee directory (`/employees`) + profile (`/employees/:employeeId`) + +- **Purpose**: find, inspect, and manage the employee lifecycle. +- **Directory**: virtualized table (§6) over `GET /employees` (cursor pagination, server filters: branch, department, position, status, employmentType, `q`). Columns: code, name+avatar, branch, department, position, status chip, joinDate. Toolbar: filters (all in URL), column chooser, CSV export (server-side job for > 10k rows), "Add employee" (`employee:create`). Row click → profile. Bulk select → assign shift, move branch (each a confirmed batch mutation with per-row result report). +- **Profile tabs**: Overview (identity, org placement, manager chain), Attendance (embedded `GET /attendance/days?employeeId&from&to` month grid), Leave (balances `GET /leave/balances?employeeId` + request history), Payroll (visible only with `payroll:read` — `EmployeeSalary` history + payslips), Documents (`EmployeeDocument` list, upload/verify), Devices (bound devices, revoke via `DELETE /devices/{id}`), Roles (RoleAssignments — `role:assign` only). +- **Primary actions**: edit profile, deactivate (`POST /employees/{id}/deactivate` with exit-date dialog and downstream-impact summary: open approvals, roster slots, payroll inclusion), reset device binding. +- **States**: directory empty = onboarding CTA "Import employees" ; profile 404 = "Employee not found in {company}" with back-to-directory. + +### 3.3 Roster planner (`/rosters/planner?branchId&week`) + +- **Purpose**: build and publish weekly shift rosters per branch (primary `BRANCH_MANAGER` surface). +- **Data**: `GET /rosters?branchId&from&to` (roster grid), `GET /shifts` (palette), employee list for the branch. +- **Components**: week grid — rows = employees (virtualized), columns = 7 days; cell = `ShiftAssignment` chip (shift code + color, `source` glyph for ROSTER/ROTATION/MANUAL/SWAP); left panel shift palette; drag-and-drop assign/move/copy (keyboard equivalent: cell focus + palette picker, §7); conflict badges computed client-side and re-validated server-side (double assignment, leave overlap, night-shift rest-period rule); coverage footer per day (assigned vs required headcount); copy-last-week; unpublished-changes tray. +- **Primary actions**: edit cells (buffered locally), **Publish** = single `PUT /rosters?branchId&from&to` with the week's assignment set and `Idempotency-Key`; discard draft. Publish is blocked while hard conflicts exist. +- **States**: unpublished-draft banner with count; publish partial failure → per-cell error markers and the response's problem detail; week with no roster = "Start from shift rotation" / "Copy previous week" CTAs; roster locked (Cloud Scheduler lock, master spec §2) = read-only banner with lock timestamp. + +### 3.4 Attendance monitoring (`/attendance/live`, `/attendance/exceptions`) + +- **Live Board**: near-real-time presence for the selected scope. Data: `GET /attendance/days?from=today&to=today` + recent `GET /attendance/punches`, polled every 60 s (TanStack Query `refetchInterval`; no websockets in P3). Components: status summary chips (present/absent/late/on-leave/not-yet-in vs shift), virtualized employee grid with last punch time/method/insideFence flag, branch/shift filters, punch-detail drawer (map snippet with punch point vs geofence circle, method, device, `serverValidated`, `invalidReason`). Read-only; `attendance:read` scope-filtered. +- **Exceptions Queue**: actionable list of invalid or suspicious records: punches with `serverValidated=false` or `invalidReason` set (out-of-fence, integrity failure, mock location, speed-of-travel — `07-security-architecture.md` §6), missing OUT punches, `AttendanceDay.status=PENDING`. Grouped by exception type; each row: employee, timestamp, evidence panel, actions **Approve as valid** / **Reject** / **Request regularization** (each `attendance:approve`, each writes an audit log). Bulk approve limited to same exception type ≤ 50 rows. +- **Regularizations** (`/attendance/regularizations`): pending `RegularizationRequest` list → detail with requested vs recorded times diff → `POST /attendance/regularizations/{id}/decide`. +- **States**: live board outside working hours = subdued "No active shifts right now"; exception queue empty = positive empty state ("No exceptions — everything checks out"); poll failure = stale-data banner with last-updated timestamp, board keeps rendering cached data. + +### 3.5 Leave approvals (`/leave/approvals`) + +- **Purpose**: decide pending leave requests at company/branch scope (multi-level chains). +- **Data**: `GET /leave/requests?status=PENDING` (+ scope filters); decision via `POST /leave/requests/{id}/decide`. +- **Components**: queue list (requester, type chip with `colorHex`, dates + day count incl. half-day glyphs, waiting-since, chain position "step 2 of 3"); detail drawer: reason, attachment viewer (`requiresAttachment` types), requester's balance snapshot (`LeaveBalance` incl. `pendingDays`), team-coverage calendar for the request window (who else is off), policy verdict panel (notice period, max consecutive, balance sufficiency — server-computed, surfaced verbatim); approve/reject with mandatory comment on reject. +- **Primary actions**: decide single; bulk approve (only requests with green policy verdicts, ≤ 25); reassign approver (`COMPANY_ADMIN`/`HR_ADMIN`). +- **States**: decision conflict (already decided elsewhere / on mobile) → 409 handled by removing the row with an info toast, never double-applying; empty = "Queue clear". + +### 3.6 Payroll run wizard (`/payroll/runs/new`, resumable at `/payroll/runs/:runId`) + +Five steps mapped to `PayrollRun.status` (`DRAFT → CALCULATING → REVIEW → APPROVED → PAID|CLOSED`); the wizard is resumable — reopening a run routes to the step implied by its status. Step state lives in the URL (`?step=`) and the run resource, never in component memory. + +| Step | Name | Contents | Exit criteria | +|---|---|---|---| +| 1 | **Scope** | Period (year/month), branch multi-select (`branchIds`), included-employee preview count with exclusions list (joined mid-period, exited, missing `EmployeeSalary`) | `POST /payroll/runs` creates DRAFT | +| 2 | **Inputs** | Readiness checklist: attendance days finalized (no `PENDING` in period), leave/LOP applied, overtime totals, unapproved regularizations blocking; per-item drill-through links; ad-hoc input adjustments (bonus/deduction rows) | All blocking checks green or explicitly waived (`payroll:run`, waiver audited) | +| 3 | **Calculate** | Triggers async calculation (Cloud Tasks, master spec §5); progress panel polls run status while `CALCULATING` (N of M payslips); cancel returns to DRAFT | Server sets REVIEW | +| 4 | **Review** | Totals vs previous period (gross/net/deductions variance % with configurable alert threshold), per-employee payslip table (virtualized) with drill-in to `PayslipLine`s, anomaly flags (net < 0, > X% swing, missing components), recalculate-subset action | Reviewer marks reviewed | +| 5 | **Approve** | Summary card, mandatory re-authentication (recent Firebase sign-in), typed confirmation of period, `POST /payroll/runs/{id}/approve`; post-approve: payslip publication + PDF generation status, mark PAID | Run APPROVED; wizard becomes read-only record | + +- **RBAC**: steps 1–4 require `payroll:run`; step 5 requires `payroll:approve`, and the approver must differ from `startedBy` (segregation of duties, enforced server-side — `07-security-architecture.md` §2). HR_ADMIN sees steps 1–2 contribution views only. +- **States**: CALCULATING failure → step 3 shows the job's problem detail with "Retry calculation"; a run locked (`lockedAt`) renders the whole wizard read-only with the lock reason. + +### 3.7 Audit log explorer (`/audit`) + +- **Purpose**: forensic, filterable view of the append-only `AuditLog` (primary `AUDITOR` surface). +- **Data**: `GET /audit-logs?resourceType&from&to` (+ actor, action, resourceId filters), cursor pagination. +- **Components**: filter bar (all URL-backed: time range with presets, actor picker, action, resourceType, resourceId); virtualized result table (at, actor + role, action, resource link, ip); detail drawer with **before/after JSON diff** viewer (side-by-side, changed keys highlighted, PII fields render redacted per classification — `07-security-architecture.md` §7.3); export to CSV (audited); saved filter sets (local). +- **States**: over-broad query (> 30 days, no filter) prompts narrowing before fetch; empty = "No audit events match"; explorer is strictly read-only for every role — there is no mutating action on this screen by design. + +### 3.8 Company settings (`/settings/*`) + +- **Company Profile**: name, legalName, timezone, currency (currency change requires typed confirmation and is blocked while any non-CLOSED payroll run exists), plan display. +- **Branches & Geofences**: branch CRUD (`/branches`); per-branch geofence editor — interactive map with draggable center pin and radius handle bound to `lat/lng/radiusM` (min radius 50 m, warning under 100 m for GPS accuracy), address search, multiple `Geofence` rows per branch with active toggles; changes affect punch validation immediately — the save dialog states this and links affected shift population count. +- **Shifts**: `Shift` CRUD; start/end with overnight (`isNight`) handling, break/grace minutes, `overtimePolicyJson` edited through a structured form (threshold, multiplier, rounding) — never raw JSON; deactivation blocked while future `ShiftAssignment`s reference the shift (offer bulk-reassign). +- **Leave Policies**: `LeaveType` CRUD (color, paid, attachment-required) and `LeavePolicy` per type (accrualRule NONE/MONTHLY/YEARLY/ANNIVERSARY, accrualDays, maxBalance, maxCarryover, minNoticeDays, maxConsecutiveDays, appliesTo audience builder); simulation panel: "for employee X, next accrual on date Y grants Z days"; policy edits apply prospectively — banner clarifies no retroactive rebalancing without an explicit HR tool. +- **Salary Components**: `SalaryComponent` CRUD (EARNING/DEDUCTION/EMPLOYER_COST; calc FIXED/PERCENT_OF_BASIC/PERCENT_OF_GROSS/FORMULA with a validated formula editor — known variables, live preview against a sample salary), `taxable`, `statutoryCode`; `SalaryStructure` composer (ordered component list, preview payslip); components used by any non-CLOSED run are edit-locked. +- **Holiday Calendars**: per-year calendars, branch mapping (`branchIds`), optional-holiday flags; import national presets. +- **Roles & Permissions**: role catalog (built-in read-only + custom roles), permission-set editor grouped by resource, `RoleAssignment` management with scopeType COMPANY/BRANCH/DEPARTMENT; every change here is highlighted as audited and takes effect on next token refresh (`07-security-architecture.md` §3.3). + +All settings mutations are confirmed (destructive ones with typed confirmation), audited, and follow the pessimistic write policy (§5.3). + +### 3.9 Announcements (`/announcements`) + +- **Purpose**: publish and manage company/branch communications (`Announcement` entity). +- **Data**: `GET /announcements` (list incl. scheduled/expired with status filter), `POST /announcements` (`announcement:publish`). +- **Components**: list table (title, audience summary, priority, publishAt, expiresAt, createdBy, delivery state); composer drawer — title, rich-text-lite body (bold/lists/links only), audience builder producing `audienceJson` (company-wide / branches / departments / employment types, with live recipient-count preview), `publishAt` scheduler, optional `expiresAt`, priority (NORMAL/HIGH — HIGH triggers push notification, stated in the composer). +- **Primary actions**: publish now, schedule, edit-before-publish (published announcements are immutable — corrections publish a follow-up), expire early. +- **States**: recipient count of zero blocks publish with audience-fix hint; scheduled items show countdown; `BRANCH_MANAGER` composer locks audience to own branches. + +### 3.10 Documents (`/documents`, and per-employee tab in §3.2) + +- **Purpose**: manage `EmployeeDocument` records (contracts, IDs, certificates) with verification workflow. +- **Data**: document list per employee (or company-wide expiring-documents view), upload via API-issued signed URL, verify action setting `verifiedBy`. +- **Components**: expiring-soon dashboard strip (documents with `expiresAt` within 90/30/7 days, filterable by kind/branch); per-employee document table (kind, name, size, mime icon, expiry chip, verified badge with verifier); upload dropzone (type/size validation client-side, virus-scan status from server before the row becomes downloadable); in-browser preview for PDF/images via short-lived signed URLs — never long-lived public links. +- **Primary actions**: upload (`document:write`), verify (`document:verify`, requires viewing the document first — the verify button unlocks after preview open), replace (versioned; prior version retained per retention policy), delete (typed confirmation, audited). +- **States**: quarantined upload (scan pending/failed) shows a non-downloadable row with status; empty per-employee = checklist of expected kinds from the onboarding template (P2). + +## 3.11 Cross-screen interaction standards + +- **Drawers over full navigations** for detail/inspect flows (exception detail, leave detail, audit entry) — the underlying list keeps its scroll and filter state; the drawer's open state and subject id live in the URL so it survives refresh and is shareable. +- **Confirmation tiers**: (1) plain confirm dialog for reversible actions; (2) consequence-summary dialog (shows affected counts) for cascading actions; (3) typed confirmation (entity name or period) for destructive/financial actions — deactivate employee, approve payroll run, change currency, delete document. +- **Date handling controls**: every date-range filter offers presets (today, this week, this month, last month, custom); custom ranges over 92 days on heavy endpoints (attendance days, audit) require explicit "run large query" acknowledgment. +- **Toasts** confirm completed mutations with an undo affordance only where a true inverse operation exists (never for payroll/roster publish); all toasts are announced via the polite live region (§6). + +## 4. Routing and URL state + +- Route tree mirrors §2.1; every screen's *complete* view state — filters, search text (debounced), cursor, sort, selected row id, wizard step, drawer open — is encoded in search params via a typed `useUrlState` hook (schema-validated, defaults elided). Guarantees: deep-linkable, refresh-safe, back/forward-correct, shareable between admins ("look at this exception"). +- Route guards: `RequireRole` / `RequirePermission` wrappers redirect unauthorized entries to Dashboard with a toast; guard config is generated from the same permission catalog constants the sidebar uses. + +## 5. Server-state management + +### 5.1 TanStack Query conventions + +- **Query keys** are structured tuples: `['employees', cid, filters]`, `['leave', 'requests', cid, filters]`, `['payroll', 'runs', cid, runId]`. `cid` in every key makes company switch a cache-namespace switch (plus `queryClient.clear()` on switch for defense in depth). +- **Cursor pagination** via `useInfiniteQuery`; `meta.cursor` from the API envelope is the page param. +- **Freshness tiers**: live board `staleTime: 0` + 60 s `refetchInterval`; queues/lists 30 s; reference data (shifts, leave types, components) 15 min; analytics 5 min. `refetchOnWindowFocus` on for queues, off for wizards. +- **Mutations** invalidate the narrowest sufficient keys; decision mutations also update the detail record from the response body to avoid a refetch flash. +- 401 → single token refresh retry then sign-out; 403 → permission-drift handler (refetch `GET /me`, recompute gating, toast "Your access changed"); 429/5xx → capped exponential backoff, max 3 (never for mutations without an idempotency key — the fetch wrapper attaches `Idempotency-Key` (ULID) to every POST per master spec §5, so mutation retries are safe). + +### 5.2 RBAC-driven UI gating + +- `GET /me` returns profile + roles + permission strings; a `can(permission, scope?)` helper backs a `` component and hook. +- Policy: controls the user can never use are **removed**; controls unavailable due to state (locked run, hard conflicts) are **disabled with a reason tooltip**. Gating is UX only — the server is authoritative (master spec §1.1) — so every mutating call still handles 403 gracefully. + +### 5.3 Optimistic updates policy + +| Class | Policy | Examples | +|---|---|---| +| Local-feel toggles, low blast radius | Optimistic (`onMutate` cache patch, rollback `onError` with toast) | notification read, saved filters, sidebar prefs, announcement draft edits | +| Queue decisions | **Pessimistic-fast**: row enters "deciding…" state, removed only on 2xx; 409 removes with "already decided" info | leave decide, regularization decide, exception approve | +| Money / compliance / structural | **Strictly pessimistic**: blocking confirm, spinner on the action only, no cache mutation until 2xx | payroll anything, salary edits, roster publish, geofence/policy changes, deactivation, role changes | + +Rationale: admins act on other people's records with financial consequence; a rolled-back "approved" that the admin already believed is worse than 400 ms of latency. + +### 5.4 Virtualization for 100k-employee tenants + +- All unbounded tables (directory, live board, payslip review, audit) use TanStack Virtual with fixed row height (48 px default / 40 px dense); windowed rendering keeps DOM < 100 rows regardless of dataset. +- Data windowing: `useInfiniteQuery` pages of 200; scroll position prefetches the next page at 75% depth; total counts come from `meta` when the API can provide them cheaply, else "10,000+" style indeterminate counts. +- Never client-side filter/sort over the full population: filters and sorts are server parameters (URL-backed). Client-side operations are permitted only within an already-scoped page (e.g. roster week grid for one branch). +- Roster grid virtualizes rows (employees) and keeps 7 day-columns static; drag interactions use overlay positioning to stay virtualization-safe. + +## 6. Accessibility (WCAG 2.1 AA) + +- **Keyboard**: every interaction reachable without a pointer — including roster drag-and-drop (cell focus, Enter opens shift picker, arrow-key move mode with live announcements) and map geofence editing (numeric lat/lng/radius inputs always present beside the map). +- **Structure**: landmarks (`nav`, `main`, `header`), one `h1` per screen, skip-to-content link, focus management on route change (heading receives focus), focus trap + restore in drawers/dialogs (Radix). +- **Tables**: real `` semantics preserved under virtualization (`aria-rowcount`/`aria-rowindex`), sortable headers with `aria-sort`, row actions in-tab-order. +- **Live regions**: polite announcements for async completions (calculation finished, N rows approved), poll refreshes silent. +- **Color**: 4.5:1 minimum contrast in both themes; status never conveyed by color alone (chips carry text/icons — e.g. leave type chips pair `colorHex` with the code); charts have accessible table alternatives ("view as data"). +- **Forms**: label every control, `aria-describedby` errors, `problem+json` violations mapped to fields, error summary link-list on submit failure. +- **CI gate**: axe-core on every Playwright flow; new violations fail the build. Manual screen-reader pass (NVDA + VoiceOver) per release on the five highest-traffic screens. + +## 7. Internationalization + +- ICU MessageFormat catalogs (react-intl); default `en`; no hardcoded strings in components (lint-enforced). Pseudo-locale build for expansion/RTL smoke testing; layout is logical-properties-based (`margin-inline-start`) so RTL locales work without overrides. +- Dates/times render in the **company timezone** (from `Company.timezone`) with the viewer's locale formatting; timestamps show timezone hints when viewer locale ≠ company zone. All API exchange is UTC ISO-8601; date-only fields (roster dates, leave dates) are timezone-less calendar dates and are never shifted. +- Currency amounts format with `Company.currency` (`Intl.NumberFormat`); payroll never renders a bare number without its currency. +- Translatable server content (problem details, insight texts) arrives keyed with server-side interpolation values; the client maps keys through the same catalogs. + +## 8. Performance budgets + +| Metric | Budget | Enforcement | +|---|---|---| +| Initial JS (gzipped, entry + vendor) | ≤ 250 KB; route chunks lazy-loaded per sidebar section | CI bundle-size check fails PRs over budget | +| LCP on Dashboard (P75, corporate network) | ≤ 2.5 s | Lighthouse CI on every merge to main | +| Interaction latency: table scroll at 100k rows | 60 fps target, no frame > 50 ms | Playwright trace assertion on directory scroll scenario | +| Route transition (cached data) | ≤ 200 ms to first meaningful paint | TanStack Query cache-first rendering; skeleton only on cold cache | +| API chatter | No polling faster than 60 s; no duplicate in-flight queries (Query dedupe) | Code review + MSW test asserting request counts | + +Charts lazy-load their rendering library; the map (geofence editor) loads only on its settings route. `React.memo`/stable-callback discipline is applied only where profiling shows re-render cost (virtualized rows, roster cells) — not speculatively. + +## 9. Session lifecycle and error handling + +- **Token refresh**: fetch wrapper refreshes the Firebase ID token when < 5 min from expiry; concurrent requests share one refresh promise. +- **Idle timeout**: configurable per tenant (default 30 min) — modal warning at T−2 min, then sign-out with return-URL preservation; hard cap 12 h regardless of activity for admin sessions. +- **Permission drift** (role changed mid-session): any 403 on a previously permitted action refetches `GET /me`, recomputes gating, and shows "Your access has changed"; the sidebar re-renders immediately (doc `07-security-architecture.md` §3.3). +- **Global error boundary** per route section: a crashed screen renders a recovery card (reload section / report) without unmounting the shell; errors ship to the client telemetry endpoint with release hash and `traceId` correlation to server logs — no PII in telemetry payloads. +- **Multi-tab consistency**: BroadcastChannel propagates sign-out and company switch across tabs; mutation invalidations rely on refetch-on-focus rather than cross-tab cache sync. + +## 10. Shared component inventory + +Console screens compose exclusively from the WorkTrack Web DS; screen code contains layout and wiring, not bespoke widgets. + +| Component | Used by | Notes | +|---|---|---| +| `DataTable` | Directory, live board, payslip review, audit, requests | Virtualized (§5.4), URL-bound sort/filter, column chooser, selection model, a11y table semantics (§6) | +| `EntityDrawer` | All detail/inspect flows (§3.11) | URL-bound open state, focus trap, lazy content query | +| `FilterBar` | Every list screen | Schema-driven from the screen's `useUrlState` definition; renders chips, presets, clear-all | +| `StatusChip` | Attendance/leave/payroll/run statuses | Enum-mapped color+icon+label; never color-only (§6) | +| `KpiStat` / `TrendChart` | Dashboard, payroll review | Chart lib lazy-loaded; "view as data" table fallback | +| `AudienceBuilder` | Announcements, leave policy `appliesTo`, calendar branch mapping | Emits the canonical audience JSON; recipient-count preview query | +| `GeoMapEditor` | Branch geofences | Lazy route-level load; paired numeric inputs for a11y (§6) | +| `WizardShell` | Payroll run | Step state from URL + resource status; guards forward navigation on exit criteria (§3.6) | +| `JsonDiffViewer` | Audit detail | Side-by-side, key-level highlight, classification-aware redaction display | +| `ConfirmDialog` (3 tiers) | All mutations (§3.11) | Typed-confirmation variant for tier 3 | +| `PermissionGate` (``) | Everywhere | Removes (not disables) unauthorized controls (§5.2) | +| `ProblemCard` / `EmptyState` / `SkeletonGroup` | Canonical states (§3) | problem+json mapping, correlation id display | + +## 11. Testing strategy + +| Level | Scope | Tooling / gate | +|---|---|---| +| Unit | `useUrlState` schema round-trips, `can()` gating logic, audience JSON builder, formatter utilities (currency/timezone) | Vitest; PR-blocking | +| Component | DS components incl. keyboard interaction contracts (roster cell picker, drawer focus trap), all four canonical states per screen shell | React Testing Library + axe-core assertions; PR-blocking | +| Integration | Screen ↔ API flows against MSW fake `/v1` (pagination, optimistic vs pessimistic mutation classes incl. 409/422 paths, permission-drift 403 handling, token refresh) | Vitest + MSW; request-count assertions (§8); PR-blocking | +| E2E per persona | One journey each: COMPANY_ADMIN settings edit, HR_ADMIN leave approval, PAYROLL_ADMIN full 5-step run, BRANCH_MANAGER roster publish, AUDITOR audit drill-down + export | Playwright against staging seed tenant; axe scan per page visited; release-blocking | +| Visual regression | DS components + dashboard/roster/wizard layouts, light+dark, LTR+RTL pseudo-locale | Playwright screenshots with checked-in goldens; PR-blocking on diff | + +Seed data: a deterministic fixture tenant (3 branches, 250 employees, one closed + one draft payroll run, pending approvals in every queue) is rebuilt per E2E run so tests never depend on mutable shared state. diff --git a/docs/07-security-architecture.md b/docs/07-security-architecture.md new file mode 100644 index 0000000..f5028d3 --- /dev/null +++ b/docs/07-security-architecture.md @@ -0,0 +1,306 @@ +# WorkTrack — Security Architecture + +Version: 1.0 · Status: Approved · Derives from: `00-master-spec.md` (§2.1, §5, §7) · Companions: `05-android-architecture.md`, `06-web-admin-design.md`, `08-sync-strategy.md` + +**Purpose.** This document is the platform security specification for WorkTrack: the threat model, identity and custom-claims design on Firebase Auth, the deny-by-default authorization chain and full permission catalog, the Firestore security-rules strategy, the attendance anti-fraud stack (device binding, Play Integrity, kiosk TOTP, face verification) with its biometric privacy posture, data protection and compliance controls (PII classification, GDPR, SOC 2 mapping), and the secure development lifecycle. Every control here is normative for backend, Android, and web implementations; exceptions require a documented risk acceptance signed by the security owner. + +--- + +## 1. Security objectives and trust boundaries + +Objectives, in priority order: (1) **tenant isolation** — no data or action ever crosses a `companyId` boundary; (2) **payroll and attendance integrity** — money-bearing records cannot be forged, replayed, or silently altered; (3) **PII/biometric confidentiality**; (4) **accountability** — every privileged mutation is attributable and immutable in audit. + +Trust boundaries: mobile devices and browsers are **untrusted** (they propose, never decide — master spec §3); Cloud Functions API is the sole trusted policy-enforcement point; Firestore is reachable by clients only through security rules that treat the API as the writer of record (§5); kiosk devices are semi-trusted terminals holding a device-scoped `KIOSK` identity and no employee data. + +## 2. Threat model (STRIDE) + +| # | Threat | STRIDE | Vector | Impact | Mitigations (normative) | +|---|---|---|---|---|---| +| T1 | Spoofed GPS punch | Spoofing | Mock-location app, rooted device, GPS simulator fakes an in-fence punch | Wage fraud | Play Integrity verdict on punch (§6.2); `isMock` flag captured per fix (§6.3); server geofence re-validation from raw lat/lng; speed-of-travel plausibility (§6.4); punches flagged not silently dropped → exceptions queue | +| T2 | Face photo/video replay | Spoofing | Photo of an employee shown to camera for face punch | Buddy punching | On-device liveness (ML Kit) before embedding; server-side match threshold tunable (§6.6); face punch bound to bound device + integrity token; anomaly review queue | +| T3 | Token theft | Spoofing / Elevation | Stolen Firebase ID/refresh token from device backup, malware, or network | Account takeover | Short-lived ID tokens (≤ 1 h); refresh token bound to Firebase installation; tokens stored only in Keystore-backed EncryptedSharedPreferences (§7.2); TLS 1.2+ everywhere; punch endpoints additionally require bound `deviceId` + integrity token, so a bare token cannot punch (§6.1); revocation flow (§3.4) | +| T4 | Tenant isolation breach | Info disclosure / Tampering | Crafted `companyId` in URL/body differing from token; IDOR on ULIDs | Cross-company data leak | Tenant resolved **only** from verified claims; URL/body `companyId` must equal `cid` or 403 (master spec §2.1); every Firestore access path is `companies/{cid}/…` derived from claims; no cross-tenant queries exist in the API; ULIDs are non-guessable but never relied on as secrets | +| T5 | Privilege escalation | Elevation | Client-forged role list; role changed via unprotected endpoint; stale claims after demotion | Unauthorized admin actions | Roles live in custom claims set only by backend admin SDK on `RoleAssignment` change (§3.3); deny-by-default RBAC middleware (§4); claims revocation + `auth_time`/`iat` check against `claimsUpdatedAt` for sensitive scopes; role management itself requires `role:assign` and is audited | +| T6 | Kiosk token replay | Spoofing / Replay | Screenshot/relay of kiosk QR used later or from elsewhere | Remote buddy punching | TOTP QR: 30 s window, HMAC-signed over (kioskId, timeStep) with per-kiosk secret (§6.5); server accepts current ±1 step once — **single-use enforcement** via consumed-token cache keyed (kioskId, timeStep, employeeId); kiosk branch must match employee branch; kiosk secret rotation | +| T7 | Insider payroll fraud | Tampering / Repudiation | PAYROLL_ADMIN inflates a salary, edits a closed run, or approves own run | Financial loss | Segregation of duties: `payroll:approve` requires approver ≠ `startedBy` (server-enforced); runs immutable after `lockedAt`; salary changes require `salary:write` + audit with before/after; approve step requires recent re-authentication; variance alerts in review step (doc 06 §3.6); immutable audit log (§7.5) | +| T8 | Punch record tampering | Tampering | Client edits/deletes a synced punch to erase lateness | Attendance fraud | Punches are append-only at every layer: no update/delete API, Room exposes no update DAO, Firestore rules deny all client writes (§5); `AttendanceDay` is a server-computed projection clients cannot write | +| T9 | Sync replay / duplicate mutation | Tampering | Replayed `POST /sync/push` batch or duplicated outbox delivery | Double leave requests, duplicate punches | ULID `Idempotency-Key` per op, honored on all POSTs (master spec §5); idempotency store returns the original result for replays (doc 08 §4.2) | +| T10 | Audit log erasure | Repudiation | Compromised admin deletes audit trail | Untraceable fraud | `auditLogs` append-only: no update/delete in API or rules; BigQuery export as second copy (§7.5); AUDITOR role reads independently of COMPANY_ADMIN | +| T11 | PII exfiltration via logs/exports | Info disclosure | PII in application logs, over-broad exports | Privacy breach, GDPR exposure | Structured log redaction (§7.4); export endpoints permission-gated and audited; PII classification drives field-level handling (§7.3) | +| T12 | Denial of service on API | DoS | Credential-stuffing bursts, sync-push floods | Availability loss | Per-identity and per-IP rate limits at the API layer; sync batch caps + backpressure signals (doc 08 §4.4); Firebase Auth built-in abuse protection; Cloud Functions autoscaling with per-tenant quota guards | + +Residual risks are tracked in the risk register with owners and review dates; T2 liveness bypass by sophisticated 3D masks is accepted-with-monitoring at P1 (compensating control: exceptions queue + device binding). + +## 3. Identity + +### 3.1 Authentication flows + +- **Android**: Firebase Auth (email/password; SSO providers per tenant plan). SDK manages refresh; the app never touches raw refresh tokens. Post-auth, the session is not usable until device binding (`POST /devices`) succeeds (doc 05 §5.1). +- **Web Admin**: Firebase Auth Web SDK; console rejects sessions holding no admin role (doc 06 §2). Payroll approval and role management require **recent authentication** (re-auth if `auth_time` older than 15 min). +- **Kiosk**: provisioned by an admin; a device-scoped account holding only the `KIOSK` role and a kiosk registration; it can render QR tokens and nothing else — no employee reads, no punch submission (employees' apps submit punches). +- Password policy delegated to Firebase with enforced minimums (length ≥ 12, breach-list screening); email verification required before first API access; MFA (TOTP) available and mandatory for `COMPANY_ADMIN`/`PAYROLL_ADMIN` on Enterprise plan tenants. + +### 3.2 Custom claims + +Exactly as master spec §2.1: + +```json +{ "cid": "01J8…COMPANY", "r": ["BRANCH_MANAGER", "EMPLOYEE"], "b": ["01J8…BR1", "01J8…BR2"], "eid": "01J8…EMP" } +``` + +- `cid` — tenant id; single company per credential (multi-company users hold separate credentials; the web company switcher re-authenticates). +- `r` — role codes (master spec §1.1), resolved to permission sets **server-side per request** so permission-set edits to custom roles apply without re-minting tokens. +- `b` — branch scope ids for branch-scoped roles; empty for company-wide roles. +- `eid` — employee id, binding the auth identity to the `Employee` row (`authUid` back-reference verified at claim-mint time). + +Claims are minted exclusively by backend admin-SDK code paths triggered by `RoleAssignment` writes; no client input ever reaches claim values. Total claims payload kept < 1000 bytes (Firebase limit); large branch scopes (> ~30 branches) overflow to a server-side scope document referenced during tenant-context load, and `b` carries a sentinel `"*many"`. + +### 3.3 Claim propagation on role change + +1. Role mutation (`role:assign`) writes `RoleAssignment` and audit log in one transaction. +2. Firestore trigger recomputes the subject's claims, calls `setCustomUserClaims`, and stamps `claimsUpdatedAt` on the employee's auth metadata doc. +3. Old ID tokens (≤ 1 h) may still carry stale claims. Handling: **downgrade-sensitive** areas (payroll, role management, employee PII bulk read, audit export) compare token `iat` against `claimsUpdatedAt` and force refresh (401 `type: token-stale`) when older; ordinary endpoints tolerate the ≤ 1 h window because server-side permission resolution already reflects removed *permissions* for custom roles. +4. Demotion or exit additionally calls `revokeRefreshTokens(uid)`, capping staleness to the current ID token's remaining lifetime; `POST /employees/{id}/deactivate` does this plus device revocation. +5. Clients react to 401 `token-stale` with a silent `getIdToken(true)` and one retry. + +### 3.4 Session and device revocation + +- **Device revocation**: `DELETE /devices/{id}` sets `revokedAt`; punch and sync endpoints reject revoked `deviceId`s regardless of token validity; FCM token is invalidated. Surfaced in web (employee profile → Devices) and Android settings. +- **Session revocation**: `revokeRefreshTokens` on password reset, suspected compromise, exit, and admin "sign out everywhere". Middleware checks `auth_time` against revocation time on sensitive scopes. +- **Offboarding** (`status=EXITED`): disable Firebase user, revoke refresh tokens, revoke all devices, clear FCM tokens; audit entry `employee:deactivate` records the cascade. + +## 4. Authorization + +### 4.1 Middleware chain (deny-by-default) + +Every `/v1` route passes the full chain (master spec §7); a route missing an explicit permission declaration fails closed at startup (route-table lint). + +``` +verifyToken → validate Firebase ID token signature/expiry/audience; extract claims +tenantContext → resolve cid; assert URL companyId (if present) === cid; load company status (suspended tenant → 403); hydrate role→permission sets; resolve overflow branch scope +requirePermission(p) → assert p ∈ resolved permissions, else 403 problem+json `permission-denied` (no existence leaks: scope-mismatched resource reads return 404) +scopeNarrowing → inject mandatory filters from claims (branch scope, self scope) into the handler's query context (§4.3) +handler → business logic; every privileged mutation writes AuditLog in the same transaction +``` + +### 4.2 Permission catalog + +Permissions are `resource:action` strings (master spec §1.1). The catalog below is exhaustive for API v1; roles are bundles of these (built-in bundles listed in §4.4). + +| API area (master spec §5) | Endpoint(s) | Permission | +|---|---|---| +| Session | `GET /me` | *(any authenticated tenant member)* | +| Session | `POST /devices` | `device:bind` | +| Session | `DELETE /devices/{id}` | `device:revoke` (self) / `device:manage` (others) | +| Org | `GET /branches`, `/departments`, `/positions` | `org:read` | +| Org | create/update/delete branches, departments, positions | `org:write` | +| Org | `GET /employees`, `GET /employees/{id}` | `employee:read` (self always permitted for own record) | +| Org | `POST/PUT /employees` | `employee:create` / `employee:write` | +| Org | `POST /employees/{id}/deactivate` | `employee:deactivate` | +| Attendance | `POST /attendance/punches` | `attendance:punch` (self only, ever) | +| Attendance | `GET /attendance/punches`, `GET /attendance/days` | `attendance:read-self` / `attendance:read` (others) | +| Attendance | `POST /attendance/regularizations` | `attendance:regularize` (self) | +| Attendance | `POST /attendance/regularizations/{id}/decide` | `attendance:approve` | +| Shifts | `GET /shifts` | `shift:read` | +| Shifts | shift CRUD | `shift:write` | +| Shifts | `GET /rosters` | `roster:read` | +| Shifts | `PUT /rosters` | `roster:write` | +| Shifts | `POST /shift-swaps` | `shift-swap:request` (self) | +| Shifts | `POST /shift-swaps/{id}/decide` | `shift-swap:decide` | +| Leave | `GET /leave/types` | `leave:read-types` (all members) | +| Leave | `GET /leave/balances` | `leave:read-balance-self` / `leave:read-balance` (others) | +| Leave | `POST /leave/requests`, `POST /leave/requests/{id}/cancel` | `leave:request` (self) | +| Leave | `GET /leave/requests` | `leave:read-self` / `leave:read` (others) | +| Leave | `POST /leave/requests/{id}/decide` | `leave:approve` | +| Payroll | `GET /payroll/runs` | `payroll:read` | +| Payroll | `POST /payroll/runs` | `payroll:run` | +| Payroll | `POST /payroll/runs/{id}/approve` | `payroll:approve` (approver ≠ starter, enforced in handler) | +| Payroll | `GET /payslips?employeeId&year`, `GET /payslips/{id}` | `payroll:read-self` (own) / `payroll:read` (others) | +| Payroll | salary structures/components/employee salaries | `salary:read` / `salary:write` | +| Comms | `GET /announcements`, `GET /notifications`, `POST /notifications/{id}/read` | *(any member; audience-filtered)* | +| Comms | `POST /announcements` | `announcement:publish` | +| Analytics | `GET /analytics/kpis`, `GET /analytics/insights` | `analytics:read` (scope-narrowed) | +| Audit | `GET /audit-logs` | `audit:read` | +| Sync | `POST /sync/push`, `GET /sync/pull` | *(any member; every batched op re-checked against the op's own permission — sync grants nothing by itself, doc 08 §4)* | +| Documents | employee document read/upload/verify | `document:read-self` / `document:read` / `document:write` / `document:verify` | +| Roles | role & assignment management | `role:read` / `role:assign` | + +### 4.3 Scope narrowing + +Holding a permission is necessary, not sufficient; the effective scope is intersected with claims: + +- **Branch scope**: for sessions whose granting role has `scopeType=BRANCH`, `tenantContext` injects `branchId ∈ b` as a mandatory filter on every list/read and validates it on every mutation target (e.g. a `BRANCH_MANAGER` with `roster:write` can `PUT /rosters` only for `branchId ∈ b`; `leave:approve` only where the requester's `branchId ∈ b`). +- **Self scope**: `*-self` permissions resolve the target to `eid`; a request naming another employeeId under a self-only permission is 403. +- **Department scope** (`scopeType=DEPARTMENT`) narrows analogously for TEAM_LEAD. +- Narrowing is implemented as query-context injection, not handler discipline: handlers physically cannot issue an unscoped Firestore query because the tenant-context repository prefixes `companies/{cid}` and appends scope filters centrally. + +### 4.4 Built-in role bundles (summary) + +`EMPLOYEE`: all `*-self` + `attendance:punch`, `leave:request`, `shift-swap:request`, `device:bind/revoke(self)`, `org:read`, `shift:read`, `leave:read-types`. `TEAM_LEAD`: EMPLOYEE + dept-scoped `attendance:read`, `leave:read`, `leave:approve`, `attendance:approve`, `analytics:read`. `BRANCH_MANAGER`: TEAM_LEAD at branch scope + `roster:read/write`, `shift-swap:decide`, `employee:read`, `announcement:publish` (branch audience). `HR_ADMIN`: company-scoped org/employee/attendance/leave/document/announcement full set + `payroll:read` (no `payroll:approve`, no `salary:write` unless granted). `PAYROLL_ADMIN`: `payroll:*`, `salary:*`, `employee:read`, `attendance:read`, `leave:read`, `audit:read` (payroll resources). `COMPANY_ADMIN`: everything except cross-tenant. `AUDITOR`: every `*:read` + `audit:read`, zero write permissions. `KIOSK`: none (kiosk token flow only). `SUPER_ADMIN`: internal ops plane, out of tenant catalog. + +## 5. Firestore security rules strategy + +Principle (master spec §7): **the API is the only writer**; rules are defense-in-depth, not the primary policy engine. + +- **No client writes, anywhere**: `allow write: if false` on every collection under `companies/{cid}`. All mutations flow through Cloud Functions using the Admin SDK (which bypasses rules); therefore any rule-permitted client write path would be a bug — there are none. This covers T8 (punch tampering) and keeps balances/attendanceDays/payslips server-authoritative. +- **Reads, deny-by-default with narrow self-service allowances** for SDK-based reads that exist today (FCM-driven badge counts; future listeners): a client may read only documents belonging to its own employee — `notifications` where `resource.data.employeeId == token.eid`, `announcements` where the audience matches, own `employees/{eid}` profile doc. Every allowance also asserts `request.auth.token.cid == cid` (path tenant match). All other collections — `punches`, `attendanceDays`, `leaveBalances`, `leaveRequests`, `payrollRuns`, `payslips`, `employeeSalaries`, `auditLogs`, `devices`, `roleAssignments`, everything in §4.6 of the master spec — are `read: if false` to clients; the app reads them through `/v1` + sync, never through the SDK. +- **Rules mirror claims, never documents**: rules reference only `request.auth.token` (cid/eid) — no `get()` lookups, keeping rules O(1), non-bypassable via doc tampering, and cheap. +- **Storage rules** (Cloud Storage): payslip PDFs and documents are served via short-lived signed URLs minted by the API after a permission check; face-template objects have no client-readable path at all. +- Rules are code-reviewed like API code, covered by the Firestore rules emulator test suite (allow/deny matrix per collection × persona), and deployed atomically with functions. + +Normative shape (excerpt — the checked-in `firestore.rules` is generated from this pattern): + +``` +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + // Global default: nothing is readable or writable. + match /{document=**} { allow read, write: if false; } + + match /companies/{cid} { + function sameTenant() { return request.auth != null && request.auth.token.cid == cid; } + function isSelf(eid) { return sameTenant() && request.auth.token.eid == eid; } + + // Narrow self-service read allowances only; zero client writes anywhere. + match /employees/{eid} { allow read: if isSelf(eid); } + match /notifications/{nid} { allow read: if sameTenant() + && resource.data.employeeId == request.auth.token.eid; } + match /announcements/{aid} { allow read: if sameTenant(); } // audience refined server-side + // punches, attendanceDays, leaveBalances, leaveRequests, payrollRuns, payslips, + // employeeSalaries, auditLogs, devices, roleAssignments, …: no match block ⇒ denied. + } + } +} +``` + +## 6. Attendance anti-fraud stack + +Layered: each control is independently bypassable in theory; the stack plus review queues makes systematic fraud uneconomical. Signals **flag** (`serverValidated=false`, `invalidReason`) rather than drop — no silent data loss, and honest edge cases (poor GPS) stay recoverable via regularization. + +### 6.1 Device binding + +- One active `Device` per employee per platform (policy-tunable). `POST /devices` records platform, model, appVersion, FCM token, first integrity verdict; server issues the `deviceId` the client must present on every punch and sync push. +- Punch endpoints reject: unknown `deviceId`, revoked device, or `deviceId` bound to a different `eid` (mismatch is a high-severity audit event → T3, T6). +- Re-binding a new device auto-revokes the old one after a cool-down and notifies the employee (out-of-band fraud signal). + +### 6.2 Play Integrity + +- Standard-request tokens with server-issued nonces (doc 05 §7.2). Server decodes verdicts and applies tenant-tunable policy: `MEETS_DEVICE_INTEGRITY` required by default for punch acceptance; `MEETS_BASIC_INTEGRITY`-only → accept-but-flag; `MEETS_NO_INTEGRITY` / unlicensed → reject punch persistence as valid, record with `invalidReason=INTEGRITY_FAILED`. +- Verdict cached per device ≤ 15 min to bound API quota; latest verdict stored on `Device.integrityVerdict`. +- Unavailability (no Play services, API outage) degrades to accept-and-flag with `INTEGRITY_UNAVAILABLE` — availability failure must not lock out honest workforces (T1 residual accepted, exceptions queue compensates). + +### 6.3 Mock-location detection + +`isMock` per GPS fix travels with the punch payload. Server treats client flags as advisory (a compromised client lies): `isMock=true` → `invalidReason=MOCK_LOCATION`; absence of the flag proves nothing, hence §6.4. + +### 6.4 Speed-of-travel plausibility + +For each accepted GPS punch, server computes great-circle distance / elapsed time against the employee's previous located punch. Implied speed > threshold (default 900 km/h hard-fail; 150 km/h soft-flag, tunable) → `invalidReason=IMPLAUSIBLE_TRAVEL`. Accuracy radii are added to distance tolerance to avoid false positives; hard-fails still persist (append-only) but never auto-validate. + +### 6.5 Kiosk TOTP QR + +Per master spec §5: kiosk displays a rotating QR encoding `{kioskId, timeStep, sig}` where `sig = HMAC-SHA256(kioskSecret, kioskId ‖ timeStep)`; 30 s step. Employee app scans and submits `POST /attendance/punches {method: QR, kioskToken}`. Server verification, in order: kiosk exists/active → HMAC valid → timeStep ∈ {now−1, now, now+1} → **single-use**: `(kioskId, timeStep, employeeId)` unseen (consumed-token cache, TTL 120 s) → kiosk branch == employee branch → device binding + integrity as for GPS. Kiosk secrets: 256-bit, per kiosk, stored in Secret Manager, rotated 90 d or on suspicion; kiosk clock drift monitored via its token-refresh calls (drift > 1 step alerts ops). Screenshot relay within the 30 s window from a colleague *at the same branch* remains the residual (T6); single-use-per-employee plus device binding bounds it to self-punching in person-adjacent time, and face method (P1) closes it where required. + +### 6.6 Face verification (P1) + +- Enrollment: consented capture → on-device quality/liveness gate → embedding computed → embedding uploaded over TLS to Cloud Storage (CMEK-optional path per master spec §7); **raw capture deleted immediately after embedding extraction, on device and never stored server-side**. +- Verification punch: on-device liveness (ML Kit) → embedding → server compares against enrolled template; match threshold is **server-tunable per tenant** (`faceScore` recorded on the punch); below-threshold → `invalidReason=FACE_MISMATCH`, flagged not dropped. +- Thresholds calibrated against false-accept ≤ 0.1% at operating point; drift review quarterly. + +### 6.7 Biometric privacy + +- **Embeddings only** — no raw face images at rest anywhere (master spec §7). Embeddings are classified Restricted-Biometric (§7.3), encrypted at rest, access limited to the verification service path; not exportable via any API. +- **Consent**: explicit, per-employee, recorded (who/when/policy-version) before enrollment; refusal must leave a working alternative punch method (GPS/QR) — tenants enable face as optional or must document a lawful basis. +- **Deletion**: embedding deleted on consent withdrawal, employee exit (with retention respecting local law), and tenant offboarding; deletion is audited and propagates to backups per §7.6 crypto-shredding. +- **Regional law**: biometric features are tenant-configurable per jurisdiction. GDPR: biometric data = special category (Art. 9) — explicit consent + DPIA required, DPIA template shipped to tenants. US: Illinois BIPA-style statutes require written release, retention schedule, and prohibition on sale — the platform's written-consent flow and deletion schedule are designed to satisfy BIPA as the strictest baseline. Tenants operating where consent cannot be freely given in employment contexts (several EU DPAs' position) are steered to non-biometric methods; the platform never makes face the sole punch method. + +## 7. Data protection + +### 7.1 Encryption + +- **Transit**: TLS 1.2+ (TLS 1.3 preferred) for all client↔API, API↔Firestore/Storage paths; HSTS on hosting; certificate pinning is deliberately **not** used on Android (operational risk > benefit given Play Integrity + token binding), documented as a risk decision. +- **At rest**: Google-managed encryption for Firestore/Storage/BigQuery by default; CMEK option for face-template bucket and document vault on Enterprise plan (master spec §7). + +### 7.2 Client-side secret storage (Android) + +- Firebase session persisted by the SDK; every WorkTrack-managed secret — cached ID token metadata, `deviceId`, kiosk provisioning secret (kiosk build), FCM token — lives in **EncryptedSharedPreferences backed by an Android Keystore AES-256 master key** (`MasterKey`, StrongBox where available). Nothing security-bearing in plain SharedPreferences, files, or Room. +- Room holds business data only; no tokens. Database-level encryption (SQLCipher) is not applied by default (device FDE + no-secrets-in-Room); tenants may require it via managed-config flag. +- `android:allowBackup="false"` for security-bearing stores (backup rules exclude EncryptedSharedPreferences files); screenshots blocked (`FLAG_SECURE`) on payslip and face-enrollment screens. + +### 7.3 PII classification + +| Class | Fields (canonical model, master spec §4) | Handling | +|---|---|---| +| Restricted-Biometric | face embeddings, `faceScore` context | §6.7: CMEK-optional, no API export, consent-gated, crypto-shred on deletion | +| Restricted-Financial | `EmployeeSalary.*`, `Payslip*`, `PayrollRun.totalsJson`, bank details (P2) | `salary:*`/`payroll:*` permissions only; masked in UI until reveal-click (audited); never in logs, analytics events, or push payload bodies | +| Confidential-PII | name, email, phone, `avatarUrl`, address, documents, `lat/lng` on punches, leave reasons/attachments | Encrypted at rest; log-redacted (§7.4); export audited; push notifications carry IDs + generic titles, never field values | +| Internal | org structure, shifts, rosters, policies, announcements | Tenant-scoped standard handling | +| Public | none — no WorkTrack data is public | — | + +### 7.4 Log redaction + +- Structured JSON logs only; a central serializer applies a field-level **allowlist** — unknown fields are dropped, classified fields (email, phone, names, lat/lng, salary amounts, token strings) are redacted to type-tagged placeholders or salted hashes (correlatable, not reversible). +- Request logs record route template + IDs, never bodies for classified routes (`/payroll/*`, `/employees/*`, punch payloads). Correlation id (`traceId`) links logs ↔ audit ↔ problem responses. +- Log retention 30 d (app logs) / 400 d (security events); log access itself is IAM-restricted and audited (SOC 2 CC7). + +### 7.5 Audit log immutability + +- `AuditLog` is append-only (master spec §4.5): API exposes only `GET /audit-logs`; no update/delete handler exists; Firestore rules deny client writes wholesale (§5); the writer path is a dedicated service module invoked in-transaction with privileged mutations. +- Continuous export to BigQuery (append-only dataset, table-level immutability via IAM — the functions service account holds insert-only) provides the tamper-evident second copy; daily row-count/hash reconciliation between Firestore and BigQuery alerts on divergence (covers T10). +- Entries carry `beforeJson/afterJson` with classified fields redacted per §7.3 at write time — the audit trail itself must not become a PII amplifier. + +### 7.6 GDPR + +- **Roles**: tenant = controller, WorkTrack = processor; DPA + subprocessor list published; regional data residency per Firebase multi-region selection at tenant provisioning. +- **DSRs (Data Subject Requests)**: master spec §7 — API-backed workflows for access/export (machine-readable JSON of all rows keyed by `employeeId`), rectification (profile fields), erasure, and restriction. Erasure of an exited employee: identity fields overwritten with tombstone values; financial/attendance records required for statutory retention are **pseudonymized** (employeeId retained, direct identifiers severed) until their retention clock expires, then deleted. +- **Retention**: per-class schedule (payroll records per local statute, default 7 y; punches/attendance 3 y; audit 7 y; notifications 90 d; face embeddings: employment duration only). Cloud Scheduler retention jobs enforce; deletions audited. +- **Crypto-shredding**: exports, backups, and the document vault are encrypted under per-tenant (Enterprise: per-employee for biometrics) data keys; destroying the key renders residual copies unreadable, satisfying erasure across backups without backup rewrites. +- Breach handling: processor notification to controllers without undue delay (target ≤ 48 h) with scope, records affected, remediation. + +### 7.7 SOC 2 control mapping + +| Control (TSC) | WorkTrack implementation | +|---|---| +| CC6.1 Logical access | Firebase Auth + custom claims; deny-by-default RBAC (§4); MFA for admin roles | +| CC6.2/6.3 Provisioning & least privilege | RoleAssignment workflow with `role:assign` gate; scope narrowing; quarterly access review report generated from RoleAssignments + audit | +| CC6.6 Boundary protection | TLS everywhere; Firestore rules deny-by-default (§5); no public data plane | +| CC6.7 Data in transmission/removal | §7.1; signed-URL, expiring media access; crypto-shredding (§7.6) | +| CC6.8 Unauthorized software | Play Integrity on punch path (§6.2); dependency scanning (§8) | +| CC7.1/7.2 Monitoring & anomaly detection | Security event log; integrity/mock/speed flags into exceptions queue; sync-health telemetry (doc 08 §8) | +| CC7.3/7.4 Incident response | On-call runbooks, severity matrix, breach comms (§7.6); post-incident review with control updates | +| CC8.1 Change management | PR review gates, CI checks, staged rollout, rules+functions atomic deploy (§8) | +| A1.2 Availability | Multi-region Firebase, autoscaling functions, backpressure (T12); RTO/RPO stated in ops runbook | +| C1.1/C1.2 Confidentiality | PII classification (§7.3) + retention/disposal schedule (§7.6) | +| PI1 Processing integrity | Idempotency keys, server-authoritative computation, payroll segregation of duties, append-only punches, reconciliation jobs | + +## 8. Secure SDLC + +- **Dependency scanning**: Renovate for automated update PRs; `osv-scanner` (Gradle + npm) in CI, build-blocking on high/critical CVEs with an exception register; Android lint security checks; npm lockfile linting (`lockfile-lint`) against registry tampering. +- **Secrets management**: no secrets in the repo — CI secret scanning (gitleaks) blocks pushes; server secrets (kiosk HMAC keys, service credentials) in GCP Secret Manager with least-privilege service accounts and 90-day rotation; Android signing keys in Play App Signing; `.env`-style local config git-ignored with checked-in redacted examples. +- **Code review gates**: every change via PR; two-reviewer rule for security-sensitive paths (`functions/src/middleware/**`, `firestore.rules`, auth/claims code, payroll engine, crypto/storage utilities — enforced via CODEOWNERS); Firestore rules changes require the emulator allow/deny matrix suite to pass; SAST (Semgrep with the OWASP + custom tenant-isolation rulepack: flags any Firestore query not built through the tenant-scoped repository) on every PR. +- **Testing**: security unit tests are release-blocking — middleware chain (401/403 matrices per role × endpoint from the §4.2 catalog), rules emulator suite, idempotency replay, kiosk token replay/expiry, speed-of-travel cases. +- **Pen-test cadence**: external penetration test annually and before each major phase launch (P1 kiosk/face, P2 payroll, P3 web admin); scope includes tenant-isolation (T4) and payroll segregation (T7) scenarios; findings tracked to closure with 30/60/90-day SLAs by severity. Internal red-team exercise on the punch anti-fraud stack semi-annually. +- **Release hygiene**: staged rollout (internal → 10% → 100%) with crash + security-event monitoring; server deploys are versioned and one-step revertible; `/v1` deprecations follow the master spec's explicit deprecation windows. + +## 9. Security monitoring and incident response + +### 9.1 Security event taxonomy + +High-signal events emitted to the security log (distinct stream from app logs, 400-day retention, §7.4): + +| Event | Source | Default response | +|---|---|---| +| `auth.token_stale_forced_refresh`, `auth.revoked_token_use` | Middleware | Repeated revoked-token use from one IP → block + alert | +| `authz.permission_denied` (with route, permission, role set) | RBAC middleware | > 20/min per identity → alert (probing) | +| `tenant.claim_url_mismatch` | tenantContext | Always alert — should be near zero in legitimate traffic (T4 canary) | +| `device.binding_mismatch`, `device.revoked_use` | Punch/sync handlers | Alert + auto-flag subsequent punches from that identity | +| `fraud.integrity_failed`, `fraud.mock_location`, `fraud.implausible_travel`, `fraud.kiosk_replay`, `fraud.face_mismatch` | Anti-fraud stack (§6) | Feed exceptions queue; tenant-level rate anomaly → security review | +| `payroll.sod_violation_attempt` (approve own run), `payroll.locked_run_mutation` | Payroll handlers | Always alert; audited regardless of outcome | +| `audit.divergence` (Firestore↔BigQuery reconciliation) | Daily job | Page on-call (possible T10) | +| `rules.denied_client_write` | Firestore rules metrics | Any nonzero rate investigated — indicates a client bug or probing | + +### 9.2 Incident response + +- Severity matrix: SEV1 = confirmed cross-tenant access, payroll integrity compromise, or biometric data exposure; SEV2 = single-account takeover, audit divergence; SEV3 = contained fraud attempt, scanner findings in production. SEV1/2 page the on-call immediately; SEV1 additionally invokes the breach-notification clock (§7.6). +- Containment tooling (pre-built, tested quarterly): per-tenant API freeze switch, global punch-endpoint flag-only mode, bulk refresh-token revocation for a tenant, kiosk secret emergency rotation, signed-URL TTL kill-down. +- Every SEV1/2 concludes with a blameless post-incident review within 5 business days; action items land in the risk register (§2) with owners; controls in this document are updated in the same PR as the fix where applicable. diff --git a/docs/08-sync-strategy.md b/docs/08-sync-strategy.md new file mode 100644 index 0000000..252d6a1 --- /dev/null +++ b/docs/08-sync-strategy.md @@ -0,0 +1,307 @@ +# WorkTrack — Offline-First Synchronization Strategy + +Version: 1.0 · Status: Approved · Derives from: `00-master-spec.md` (§3, §4.5, §5, §6.3) · Companions: `05-android-architecture.md`, `07-security-architecture.md` + +**Purpose.** This document specifies the Android synchronization subsystem end to end: the outbox pattern that makes Room the local source of truth for mutations, the ULID-keyed idempotent push protocol, the cursor-based delta pull, per-resource conflict resolution, WorkManager scheduling under Doze, and the failure-handling and observability contract that guarantees rejected work is surfaced to the user — never silently lost. It is the binding contract for `core:sync` and the server's `/sync` endpoints; both sides must evolve together. + +--- + +## 1. Goals and constraints + +| # | Goal / constraint | Consequence in design | +|---|---|---| +| G1 | **Multi-day offline** operation (field workforces: sites without coverage for shifts or whole rotations) | All reads from Room; outbox durable across process death and reboots; no TTL on queued mutations; cursors resume, never restart | +| G2 | **100k-employee tenants** must not melt the client or the API | Client syncs only its own slice (self + role scope); pull is paginated + batched; push batches capped; server backpressure honored (§4.4) | +| G3 | **Server-authoritative money paths** (attendance validity, balances, payroll — master spec §3) | Client never resolves conflicts on these; push responses reconcile local rows; some resource types are pull-only (§6) | +| G4 | No duplicate side effects despite retries and replays | ULID `idempotencyKey` per op; server idempotency store returns the original result on replay (§4.2) | +| G5 | Causal ordering where it matters (punch IN before OUT; leave apply before cancel) | FIFO **per resource** drain order (§3.4) | +| G6 | No silent data loss (master spec §6.3.6) | Terminal failures become user-visible notifications with actions (§8); quarantine, never delete (§7) | +| G7 | Battery and data budget compatible with a device that punches twice a day | Periodic sync ≥ 15 min interval, batched, delta-only; expedited work reserved for user-initiated actions (§5) | +| G8 | Tenant isolation and RBAC hold on the sync path | `/sync/*` runs the full middleware chain; each pushed op is re-authorized individually (`07-security-architecture.md` §4.2) | + +Non-goals: peer-to-peer sync, multi-device merge for one employee's drafts (last writer wins via server), and web offline (the admin SPA is online-only, doc 06 §1). + +## 2. Component overview + +``` +UI ──event──▶ ViewModel ──▶ UseCase ─┬─▶ Repository (core:data) + │ │ Room txn: upsert row (syncStatus=PENDING) + │ │ + insert OutboxEntry + │ └─▶ SyncRequester.requestExpedited() +Room (source of truth) ◀── reconcile ──┐ + │ +core:sync SyncWorker ── drain outbox ─┴─▶ POST /sync/push + (WorkManager) then delta ────▶ GET /sync/pull?types&cursor +``` + +`core:sync` owns: `SyncWorker` (single entry point), `OutboxProcessor` (push), `DeltaPuller` (pull), `SyncScheduler` (WorkManager wiring), `SyncHealthTracker` (telemetry). Repositories in `core:data` own enqueueing; features never touch the outbox directly (doc 05 §2). + +### 2.1 Room schema (sync tables) + +```sql +CREATE TABLE outbox_entry ( + id TEXT PRIMARY KEY, -- ULID + op_type TEXT NOT NULL, -- CREATE|UPDATE|DECIDE|CANCEL|READ_RECEIPT + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + idempotency_key TEXT NOT NULL UNIQUE, + attempts INTEGER NOT NULL DEFAULT 0, + crash_count INTEGER NOT NULL DEFAULT 0, -- poison detection (§7) + last_error TEXT, + before_json TEXT, -- rollback snapshot for UPDATE ops (§7) + state TEXT NOT NULL DEFAULT 'PENDING', -- PENDING|IN_FLIGHT|DONE|FAILED + queued_at INTEGER NOT NULL +); +CREATE INDEX idx_outbox_drain ON outbox_entry(state, resource_id, queued_at, id); -- FIFO-per-resource pick +CREATE INDEX idx_outbox_resource ON outbox_entry(resource_type, resource_id); + +CREATE TABLE sync_cursor ( + resource_type TEXT PRIMARY KEY, + cursor TEXT NOT NULL, -- opaque server token (§4.3) + last_synced_at INTEGER NOT NULL +); +``` + +Punch round trip (happy path): + +```mermaid +sequenceDiagram + participant UI as Punch Screen + participant VM as ViewModel/UseCase + participant R as Room + participant W as SyncWorker + participant API as POST /sync/push + + UI->>VM: onEvent(ConfirmPunch) + VM->>R: txn: insert AttendancePunch(syncStatus=PENDING) + OutboxEntry(PENDING) + R-->>UI: Flow emits — chip "Recorded, will verify" + VM->>W: SyncRequester.requestExpedited() + W->>R: pick oldest PENDING per resource → IN_FLIGHT + W->>API: batch {idempotencyKey, op, payload+integrityToken} + API-->>W: results[APPLIED {serverValidated:true, updatedAt}] + W->>R: txn: entry→DONE; punch row ← server fields, syncStatus=SYNCED + R-->>UI: Flow emits — chip "Verified" + W->>API: GET /sync/pull?types=…&cursor + API-->>W: changes + next cursor + W->>R: txn: apply page + advance SyncCursor +``` + +## 3. Outbox pattern (push side) + +### 3.1 Enqueue contract + +Every offline-capable mutation is one **atomic Room transaction**: + +1. Upsert the domain row optimistically with `syncStatus = PENDING` (for creates, the client generates the entity's ULID id — offline-generatable and sortable, master spec §4). +2. Insert an `OutboxEntry` (master spec §4.5): `id` (ULID), `opType` (`CREATE|UPDATE|DECIDE|CANCEL|READ_RECEIPT`…), `resourceType`, `resourceId`, `payloadJson` (the API request body), `idempotencyKey` (fresh ULID, minted once at enqueue and never regenerated), `attempts = 0`, `state = PENDING`, `queuedAt`. + +Because both writes commit together, a crash can never produce a visible optimistic row without its outbox entry or vice versa (doc 05 §5.5). Punches additionally have **no** UPDATE/DELETE opTypes at all — append-only end to end (master spec §6.3.5). + +### 3.2 Lifecycle state machine + +```mermaid +stateDiagram-v2 + [*] --> PENDING : enqueued (atomic with optimistic Room write) + PENDING --> IN_FLIGHT : picked by OutboxProcessor (oldest first per resource) + IN_FLIGHT --> DONE : 2xx ack — reconcile row, syncStatus=SYNCED + IN_FLIGHT --> PENDING : transient failure (network, 408/429/5xx)\nattempts++, lastError set, backoff + IN_FLIGHT --> FAILED : permanent rejection (400/403/404/409/422)\nor attempts ≥ maxAttempts + FAILED --> PENDING : user retry (only for retryable classes) + FAILED --> [*] : resolved/discarded via explicit user action (audited locally) + DONE --> [*] : pruned after 7 days (kept for diagnostics) +``` + +Retry policy: + +| Failure class | Examples | Transition | Policy | +|---|---|---|---| +| Transient | offline, timeout, 408, 429, 500–504 | → PENDING | Retry with WorkManager exponential backoff (§5); `attempts` unbounded for connectivity, bounded at `maxAttempts = 10` for server 5xx | +| Permanent — business rejection | 422 (stale balance, policy violation), 409 (already decided), 403 | → FAILED (terminal) | Never auto-retried; reconcile per conflict matrix (§6) + notify (§8) | +| Permanent — malformed | 400 schema errors | → FAILED (quarantine, §7) | Client bug; telemetry alert | +| Crash recovery | app killed while IN_FLIGHT | IN_FLIGHT → PENDING at worker start | Safe because replay with the same `idempotencyKey` is a no-op server-side | + +### 3.3 Idempotency keys + +- One ULID `idempotencyKey` per logical operation, minted at enqueue, immutable across all retries of that entry — this is what makes at-least-once delivery safe (G4). +- Sent per-op inside the push batch (and as the `Idempotency-Key` header for direct non-sync POSTs, master spec §5). +- Server keeps an idempotency store keyed `(cid, idempotencyKey)` with the canonical response, retained ≥ 30 days ≥ any realistic offline window; replays return the stored outcome without re-executing side effects. + +### 3.4 Ordering — FIFO per resource + +- Drain order: entries grouped by `resourceId`, groups processed oldest-first (`queuedAt`, tie-break `id` — ULIDs are time-sortable), **strictly sequential within a group**: entry N+1 for a resource is not sent until N reaches DONE or FAILED. +- A FAILED head entry **blocks its own resource's queue** (dependent ops would be nonsense — e.g. cancel of a leave request whose create was rejected); the blocked entries fail fast with `lastError = "blocked by "` and reconcile together (§8). +- Across different resources there is no ordering guarantee, which permits batching (§4.1) and prevents one poisoned resource from stalling the world. Punch IN/OUT pairs share `resourceType=punch` but are distinct append-only resources; their causal order is preserved because the batch preserves enqueue order within a push and the server orders by `punchedAt` (client timestamp) anyway — `AttendanceDay` computation is order-insensitive by design. + +## 4. Wire protocol + +Derived from master spec §5 (`POST /sync/push`, `GET /sync/pull?types&cursor`; envelope `{ data, meta }`; RFC 7807 errors; bearer auth). + +### 4.1 `POST /sync/push` + +Request — up to **50 ops** per batch, enqueue order preserved: + +```json +{ + "deviceId": "01J8…DEV", + "ops": [ + { + "idempotencyKey": "01J9AB…", + "opType": "CREATE", + "resourceType": "punch", + "resourceId": "01J9AA…", + "payload": { "type": "IN", "method": "GPS", "punchedAt": "2026-07-17T08:58:12Z", + "lat": 52.52, "lng": 13.40, "accuracyM": 12, "insideFence": true, + "geofenceId": "01J8…GF", "isMock": false, "integrityToken": "…" } + } + ] +} +``` + +Response — **per-op results** (the batch itself is not transactional): + +```json +{ + "data": { "results": [ + { "idempotencyKey": "01J9AB…", "status": "APPLIED", "resource": { "id": "01J9AA…", "serverValidated": true, "updatedAt": "…" } }, + { "idempotencyKey": "01J9AC…", "status": "REJECTED", + "problem": { "type": "https://api.worktrack.app/problems/stale-leave-balance", + "title": "Insufficient leave balance", "detail": "Requested 3.0 days, available 1.5" } }, + { "idempotencyKey": "01J9AD…", "status": "DUPLICATE", "resource": { "…": "…" } } + ] }, + "meta": { "throttle": null } +} +``` + +- `APPLIED` → entry DONE; response `resource` fields overwrite the local row (**server fields win**, master spec §6.3.4), `syncStatus = SYNCED`. +- `DUPLICATE` (idempotency replay) → treated exactly as APPLIED. +- `REJECTED` → entry FAILED with the problem stored in `lastError`; reconciliation per §6. +- Each op is individually re-authorized against the §4.2 permission catalog and tenant scope (`07-security-architecture.md`); a whole-batch 401/403 occurs only for token-level failures. + +### 4.2 Server-side apply semantics + +Per op: idempotency-store hit → return stored result; else validate (schema → RBAC/scope → business rules) → apply in a Firestore transaction stamping server `updatedAt` → write audit where applicable → store result. Server `updatedAt` is authoritative and monotonic per resource — it is the pull cursor's basis. + +### 4.3 `GET /sync/pull` + +- Request: `GET /sync/pull?types=punch,attendanceDay,leaveRequest,leaveBalance&cursor=&limit=500`. +- The cursor is **opaque to the client** but canonically encodes, per resource type, the pair `(updatedAt, id)` of the last delivered document; server orders by `(updatedAt ASC, id ASC)` — the ULID `id` tie-breaker makes pagination stable when many rows share an `updatedAt` (bulk server jobs like accruals or `AttendanceDay` recomputation produce exactly this). +- Response: + +```json +{ + "data": { + "changes": [ + { "resourceType": "leaveBalance", "op": "UPSERT", "resource": { "id": "…", "usedDays": 4.5, "version": 7, "updatedAt": "…" } }, + { "resourceType": "leaveRequest", "op": "DELETE", "id": "01J9…", "deletedAt": "…" } + ], + "hasMore": true + }, + "meta": { "cursor": "eyJwdW5jaCI6…" } +} +``` + +- Deletes travel as soft-delete tombstones (`deletedAt`, master spec §4); client hard-deletes local rows after applying, tombstones retained server-side ≥ 90 days so a device offline longer re-bootstraps (§4.5). +- Apply is a single Room transaction per page: upserts overwrite local rows **except** rows with `syncStatus = PENDING` (a not-yet-pushed local change is never clobbered by a pull; the subsequent push resolves it per §6). `SyncCursor(resourceType, cursor, lastSyncedAt)` is updated in the same transaction — a crash between apply and cursor save re-applies an idempotent page, never skips one. +- Scope: the server narrows pulled data exactly as reads are narrowed (self slice for EMPLOYEE; branch slice for scoped managers) — a 100k-employee tenant sends an employee only their own few hundred rows (G2). + +### 4.4 Batching and backpressure + +- Push: ≤ 50 ops/batch, loop until outbox drained or budget exhausted; Pull: `limit ≤ 500`, loop while `hasMore` within the same budget (worker time budget 9 min, well under WorkManager's 10-min cap). +- Server backpressure: 429 with `Retry-After`, or in-band `meta.throttle = { retryAfterSeconds }` on partial service; client defers remaining work to the next scheduled run honoring the hint. Per-device push rate is additionally capped server-side (T12, `07-security-architecture.md` §2). +- Payload hygiene: gzip request/response; pulls exclude heavy blobs (payslip PDFs, attachments are URL references fetched on demand). + +### 4.5 Bootstrap vs incremental + +| Mode | Trigger | Behavior | +|---|---|---| +| **Bootstrap** | First login on a device; cursor reset (tombstone horizon exceeded, schema epoch bump, tenant migration) | Ordered full pull of reference data first (company, branches, shifts, leaveTypes, leavePolicies, holidayCalendars, geofences), then self slice (employee, balances, recent `attendanceDay` 90 d, punches 30 d, leaveRequests 12 mo, payslips 24 mo), then role-scoped extras (approvals). Runs as expedited work with a blocking first-run screen only until reference data + today's slice land; the rest streams in background | +| **Incremental** | Every subsequent sync | Push outbox, then pull deltas per cursor; typical payload < a few KB | + +The server signals cursor invalidity with 410 `type: cursor-expired` → client clears that resource type's cursor and re-bootstraps **that type only**. + +## 5. Scheduling (WorkManager) + +| Work | Type | Constraints | Policy | +|---|---|---|---| +| `sync-periodic` | Unique `PeriodicWorkRequest`, 15 min (WorkManager minimum), `ExistingPeriodicWorkPolicy.UPDATE` | `NetworkType.CONNECTED` | Baseline drain + pull; batteryNotLow **not** set (punches must flow on low battery) | +| `sync-now` | Unique `OneTimeWorkRequest`, `setExpedited(RUN_AS_NON_EXPEDITED_WORK_REQUEST)` fallback, `ExistingWorkPolicy.APPEND_OR_REPLACE` | `CONNECTED` | Enqueued by `SyncRequester` on: any outbox enqueue, app foreground, connectivity regained (`NetworkCallback`), pull-to-refresh, FCM sync-nudge data message | +| Punch flush | Same `sync-now` expedited path; punch enqueue always requests expedited quota | `CONNECTED` | Punches are the latency-critical mutation; expedited work gives foreground-service-like priority without a persistent notification. If expedited quota is exhausted, falls back to ordinary one-time work — acceptable because the punch is already durably queued and optimistically visible (doc 05 §6) | +| Backoff | — | — | `BackoffPolicy.EXPONENTIAL`, initial 30 s, doubling, capped at 1 h (WorkManager `MAX_BACKOFF_MILLIS`); jitter inherent in WorkManager scheduling | + +Both work items funnel into the same `SyncWorker` (unique-work mutual exclusion prevents concurrent drains; a run-lock row in Room is a second guard). The worker is idempotent and resumable at any interruption point (§3.2 crash recovery, §4.3 transactional cursor). + +**Doze/battery**: no exemptions requested — the app never asks for `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (Play policy + battery ethics). Doze defers periodic sync to maintenance windows; that is acceptable because (a) punches ride expedited work triggered by user interaction (device is awake by definition), (b) FCM high-priority data messages nudge sync for time-sensitive server events (approval decided), and (c) everything else tolerates deferral. Telemetry tracks `queuedAt → DONE` latency percentiles to verify this holds in the field (§8). + +## 6. Conflict resolution matrix + +Policy per resource type; "client wins" never applies to server-authoritative fields anywhere (G3). + +| Resource type | Class | Client writes? | Conflict handling | +|---|---|---|---| +| `punch` (AttendancePunch) | **Append-only** | CREATE only | No conflicts possible by construction; duplicates collapsed by idempotency key; validity disputes are data (`serverValidated`, `invalidReason`), not conflicts | +| `attendanceDay` | **Server-authoritative projection** | Never | Pull-only; local row always overwritten (versioned via `version` field, stale pulls with lower `version` discarded) | +| `leaveBalance` | **Server-authoritative** | Never | Pull-only; `pendingDays` overlay for optimistic UI is display-time arithmetic, never persisted into the balance row (doc 05 §6) | +| `payslip` / `payslipLine` / `payrollRun` | **Server-authoritative** | Never | Pull-only, immutable once published | +| `leaveRequest` (create/cancel) | **Reject-and-notify** | CREATE, CANCEL | Server validates against current balance/policy at apply time; stale-balance or policy violation → `REJECTED` op result → local row flipped to `REJECTED` with server reason + notification (§8). Cancel racing an approval: 409 `already-decided` → local row takes the server's decided state, user notified | +| `regularizationRequest`, `shiftSwapRequest` | **Reject-and-notify** | CREATE, CANCEL | Same as leaveRequest | +| Approval decisions (`leave/…/decide`, `regularizations/…/decide`, `shift-swaps/…/decide`) | **First-writer-wins (server)** | DECIDE op | Second decision gets 409 → FAILED (terminal, not retried); local state re-pulled; deciding user informed "already decided by X" | +| `employee` profile self-service fields (phone, avatarUrl, emergency contact) | **Last-write-wins per field** | UPDATE (allowed fields only) | Server applies field-level LWW on `updatedAt`; pushed update returns merged row which overwrites local. Org-controlled fields (branch, position, salary linkage) are never client-writable — present in payload → 403 | +| `notificationMessage.readAt` | LWW (monotonic) | READ_RECEIPT | `readAt` only ever set, never cleared; max(readAt) wins trivially | +| Reference data (branches, shifts, geofences, leaveTypes, policies, holidays, announcements) | **Server-authoritative** | Never (admin console mutates via direct API) | Pull-only | +| `device` | Server-managed | Bind/revoke via direct API (online-only) | Not in the outbox at all | + +Guard rails: a pull never overwrites a `syncStatus=PENDING` row (§4.3); after that row's push resolves (APPLIED or REJECTED), the next pull converges it to server truth. Room migrations preserve the outbox and cursors across app updates; a destructive-migration fallback is forbidden in release builds. + +## 7. Failure handling + +- **Poison messages**: an entry that repeatedly crashes the processor (serialization bug, impossible state) is detected by a per-entry crash counter (incremented pre-processing, cleared post); at 3 crashes the entry moves to FAILED with `lastError = POISON` and processing continues with the next resource group — one bad entry cannot wedge sync (G6, §3.4 blocking is per-resource only). +- **Max-attempt quarantine**: FAILED entries are quarantined, not deleted: retained with full payload + `lastError` for 30 days, visible in a debug-accessible "sync issues" screen (user-facing summary per §8, engineer-facing detail via support bundle). Quarantined entries are excluded from drains but included in telemetry. +- **Reconciliation of the optimistic row**: whenever an entry reaches FAILED, the repository reverses or re-labels the optimistic write in the same transaction that records the failure: creates → row marked `syncStatus=REJECTED` with reason (kept, visibly, for the user to act on — e.g. re-apply leave with valid dates; punches are never deleted, they carry `invalidReason`); updates → row restored from `beforeJson` snapshot held on the entry; decides → target re-pulled. +- **Cursor integrity**: pull apply + cursor advance are transactional (§4.3); a corrupted cursor (deserialization failure) resets that type to bootstrap rather than failing sync. +- **Auth failures**: 401 → single forced token refresh + retry; second 401 aborts the run and, if the token is revoked (`07-security-architecture.md` §3.4), triggers the sign-out flow; outbox is preserved for the same user's next session and wiped on a different user's login. +- **Clock skew**: client timestamps (`punchedAt`, `queuedAt`) are recorded with the device's elapsed-realtime anchor plus an NTP-checked offset when available; server records receive time and flags punches with skew > 5 min for the exceptions queue rather than rejecting. + +## 8. Observability and UX surfacing + +### 8.1 Telemetry (SyncHealthTracker) + +Structured, PII-free events (ids + enums only, `07-security-architecture.md` §7.4): per run — duration, ops pushed/applied/rejected, pages pulled, rows applied, backoff state; per entry — `queuedAt → DONE/FAILED` latency; gauges — outbox depth, oldest-pending age, quarantine count, cursor age per resource type. Exported via the analytics pipeline with tenant-level dashboards and alerts: p95 punch sync latency > 10 min, quarantine rate > 0.1%, any poison event, cursor age > 48 h on active devices. + +An in-app diagnostics surface (Profile → Settings → Sync status) shows: last successful sync, pending count, failed count with reasons — the first thing support asks for. + +### 8.2 UX surfacing rules (no silent data loss) + +| Situation | Surface | +|---|---| +| Op pending (offline or queued) | Per-row glyph ⟳ + global offline banner (doc 05 §6); no toast noise | +| Punch flagged by server (`serverValidated=false`) | Row badge + notification "Punch recorded but flagged: . HR can review." linking to attendance history detail | +| Leave/regularization/swap rejected | Local push notification (deep link `worktrack://leave/requests/{id}`) + row state REJECTED with server reason + inline "Apply again" action | +| Decision conflict (409) | Notification "Already decided by "; approvals inbox row resolves to final state | +| Entry quarantined (poison/malformed) | Non-technical notification "Some changes couldn't be saved — tap to review" → sync issues screen listing affected items with retry/discard; discard requires explicit confirmation and is the **only** path that abandons user data | +| Sync degraded (backpressure, repeated 5xx) | Passive banner "Sync delayed — will keep retrying"; no user action solicited | + +Invariant: every terminal FAILED entry produces exactly one user-visible artifact (notification and/or persistent row state). This is asserted in the `core:sync` end-to-end test suite (doc 05 §8: rejection scenarios must observe a notification emission), making G6/master-spec §6.3.6 a tested property, not an aspiration. + +## 9. Verification matrix + +Executable acceptance criteria for `core:sync` (tooling per doc 05 §8: JVM tests, in-memory Room, MockWebServer fake server; WorkManager via `WorkManagerTestInitHelper`): + +| # | Property | Scenario asserted | +|---|---|---| +| V1 | Atomic enqueue | Kill (throw) between row upsert and outbox insert → transaction rolls back; neither is visible | +| V2 | Idempotent replay | Same batch delivered twice (network retry after response loss) → server fake returns DUPLICATE; exactly one local row, one DONE entry | +| V3 | FIFO per resource | Leave create then cancel enqueued offline → cancel never sent before create acked; create REJECTED → cancel fails fast as blocked | +| V4 | Crash mid-flight | Process death with entry IN_FLIGHT → next run resets to PENDING, resends same idempotencyKey, converges to one server record | +| V5 | Pull never clobbers pending | Local PENDING profile edit + pull carrying older server row → local row untouched; after push, next pull converges | +| V6 | Cursor transactionality | Crash between page apply and cursor save → page re-applied idempotently; no gap, no duplicate rows | +| V7 | Cursor expiry | 410 cursor-expired on one type → that type re-bootstraps; other cursors untouched | +| V8 | Rejection surfaces | 422 stale-balance on leave create → row REJECTED with reason, notification emitted exactly once (G6 invariant) | +| V9 | 409 decision race | Decide op returns 409 → entry FAILED terminal (no retry), target re-pulled to decided state, info surfaced | +| V10 | Backoff and backpressure | 500,500,200 sequence → exponential gaps honored; 429 with Retry-After defers remaining batches | +| V11 | Poison isolation | Entry that throws in serialization 3× → quarantined FAILED(POISON); other resources continue draining same run | +| V12 | Offline burst | 200 queued punches over 3 simulated days → drained in ≤ 4 batches, order preserved per resource, all SYNCED | +| V13 | Auth revocation | 401 twice → run aborts, outbox preserved; different-user login wipes outbox and cursors | +| V14 | Migration safety | Room schema bump with pending outbox entries → entries and cursors survive migration (MigrationTestHelper) | + +Server-side mirrors (functions test suite): idempotency-store replay returns byte-identical results; per-op RBAC re-check rejects an op whose permission was revoked after enqueue; tombstone horizon and 410 emission; `updatedAt` monotonicity under concurrent transactions. A release of either side must pass both suites against the shared contract fixtures (JSON golden files for §4 payloads, versioned with `/v1`). diff --git a/docs/09-roadmap.md b/docs/09-roadmap.md new file mode 100644 index 0000000..2e6e6a9 --- /dev/null +++ b/docs/09-roadmap.md @@ -0,0 +1,259 @@ +# WorkTrack — Development Roadmap + +Version: 1.0 · Status: Approved · Owners: Platform Architecture + Product · Derives from: `00-master-spec.md` §8 + +**Purpose.** This document expands the master specification's delivery phases (P0–P4) into an executable milestone plan: per-phase workstreams (Android, Backend, Web, Data/AI, Security/Compliance), concrete deliverables, exit criteria, dependency ordering, a suggested team shape, and the program risk register. It also fixes the P0 definition of done to exactly what `00-master-spec.md` §8 declares implemented in this repository. Phase numbering here is delivery-phase numbering (P0–P4) and must not be confused with requirement priorities (P0/P1/P2) in `01-product-requirements.md`. + +--- + +## 1. Phase overview and dependency ordering + +```mermaid +flowchart TD + P0["P0 — Foundation (this repo, implemented)
Android foundation + backend API core +
Firestore rules + design docs"] + P1["P1 — Scheduling & Trust
rosters UI, regularization, approvals inbox,
face verification, kiosk app mode"] + P2["P2 — Payroll
calculation engine + runs UI,
statutory packs, document vault"] + P3["P3 — Admin & Analytics
Web Admin SPA, analytics dashboards,
BigQuery pipeline"] + P4["P4 — Intelligence & Openness
AI insights, attrition/absence prediction,
anomaly detection, open APIs + webhooks"] + + P0 --> P1 --> P2 --> P3 --> P4 +``` + +Hard dependencies that fix this ordering: + +| Dependency | Reason | +|---|---| +| P1 before P2 | Payroll consumes AttendanceDay projections that are only trustworthy once regularization and roster-driven shift assignment exist (worked/late/OT minutes must be correctable and shift-aware). | +| P2 before P3 payroll dashboards | Analytics over payroll requires PayrollRun/Payslip data to exist. | +| BigQuery pipeline (P3) before AI (P4) | Model training and `/analytics/insights` features read the warehouse, not Firestore. | +| Approvals inbox (P1) before payroll approval UX (P2) | Reuses the same role-gated approvals surface on Android. | +| Kiosk mode (P1) independent of payroll | Can ship in parallel inside P1; depends only on P0 punch validation + `KIOSK` role. | +| Web Admin (P3) after API hardening (P0–P2) | The SPA consumes the same `/v1` API; shipping it against a churning payroll API would force rework. Design (`06-web-admin-design.md`) proceeds earlier; implementation is P3. | + +Soft parallelism: Security/Compliance and Data/AI workstreams run continuously; each phase below lists their concurrent obligations. + +### 1.1 Workstream map across phases + +| Workstream | P0 (done) | P1 | P2 | P3 | P4 | +|---|---|---|---|---|---| +| Android | Foundation: modules, features, offline sync | Rosters, regularization, approvals inbox, face, kiosk mode | Payroll runs UI, document vault | Directory/announcement polish | Insight surfaces | +| Backend | API core: middleware, punch, leave, sync, payslip read | Rosters/swaps/regularization/kiosk/face/accruals/holidays | Payroll engine, statutory packs, exports | Analytics API, DSR, residency | Open API, webhooks, SSO/SCIM | +| Web | — | Design finalization only | Scaffolding (late) | **Web Admin SPA + dashboards** | Insights + platform consoles | +| Data/AI | — | Event taxonomy → staging BQ | Payroll events, reconciliation | **BigQuery pipeline prod**, KPI layer | Models, anomaly detection, serving | +| Security/Compliance | Rules, middleware, token model | Integrity blocking, DPIA, kiosk secrets | SoD, retention, statutory change control | SOC 2 Type I, pen test, DSR runbook | AI governance, SOC 2 Type II | + +--- + +## 2. P0 — Foundation (this repo, implemented) + +### 2.1 P0 definition of done + +P0 is done exactly when the following — the master spec §8 P0 scope, verbatim in substance — is implemented and verifiable in this repository: + +1. **Android build foundation**: `build-logic/` convention plugins — `worktrack.android.application`, `worktrack.android.library`, `worktrack.android.library.compose`, `worktrack.android.feature`, `worktrack.android.hilt`, `worktrack.android.room`. +2. **Core modules**: `core:common`, `core:model`, `core:database`, `core:network`, `core:datastore`, `core:domain`, `core:data`, `core:sync`, `core:designsystem` — wired per the module graph in master spec §6.1 (features depend on domain/designsystem/common; data composes database/network/datastore; sync owns workers, outbox processor, scheduling). +3. **Feature modules**: `feature:auth` (Login → ForgotPassword → DeviceBinding), `feature:dashboard`, `feature:attendance`, `feature:leave`, `feature:payslips`, `feature:profile` — navigable per master spec §6.2 (AuthGraph → MainGraph, bottom bar Dashboard/Attendance/Leave/Profile, deep links `worktrack://leave/requests/{id}`, `worktrack://payslips/{id}`, `worktrack://approvals`). +4. **Offline & sync contract**: Room as local source of truth (Flow DAOs), optimistic writes with `syncStatus=PENDING`, OutboxEntry with ULID `idempotencyKey`, `SyncWorker` (network-constrained, exponential backoff, unique work) draining FIFO-per-resource then delta-pulling per SyncCursor; punches append-only; server-authoritative conflict policy with actionable rejection notifications. +5. **Backend API core** (Cloud Functions, Node 20, TypeScript, Express, `/v1`): auth/tenant/RBAC middleware chain (verify token → tenant context from claims `{cid,r,b,eid}` → permission check → handler, deny-by-default); attendance punch endpoint with validation (geofence, device binding, `serverValidated`/`invalidReason`); leave requests + decisions (approval chain, balance movements); sync push/pull (batched idempotent ops, delta cursors); payslip read endpoints. +6. **Firestore security rules**: no direct client access to server-authoritative collections; rules as second line of defense behind the API. +7. **Full design docs**: `00`–`09` document set present and mutually consistent, with `00-master-spec.md` canonical. + +Exit is binary: each of the seven items above either exists in-repo and passes its checks or P0 is not done. No partial credit; no other feature counts toward P0. + +### 2.2 P0 verification checklist + +| Check | Method | +|---|---| +| Module graph matches spec §6.1 | Gradle project structure + dependency assertions in convention plugins | +| Offline punch → sync exactly-once | Instrumented test: airplane mode punch, reconnect, assert single server record | +| Middleware chain deny-by-default | API tests: missing token 401, wrong tenant 403, missing permission 403 | +| Idempotent sync push | Replay same batch, assert no duplicate effects | +| Firestore rules deny client writes | Rules-emulator test suite over server-authoritative collections | +| Docs consistency | Cross-reference review: entities/roles/paths in 01/02/09 vs 00 | + +--- + +## 3. P1 — Scheduling & Trust + +Theme: make attendance data correct and correctable at branch scale; extend capture surfaces. + +| Workstream | Deliverables | Notes | +|---|---|---| +| Android | Roster views (my schedule, team roster for `TEAM_LEAD`/`BRANCH_MANAGER`); regularization request flow; **approvals inbox** (role-gated: leave, regularization, shift swaps); face-verification punch capture; **kiosk app mode** (rotating TOTP QR display for `KIOSK` devices) | Approvals inbox destination already navigable in P0 shell (`worktrack://approvals`) | +| Backend | `GET/PUT /rosters` + ShiftAssignment write paths; rotation generation jobs (Cloud Scheduler → batched Cloud Tasks); roster locks; `POST /attendance/regularizations` + `/decide` with AttendanceDay recompute; `POST /shift-swaps` + `/decide`; kiosk token issuance/verification (HMAC, 30 s window, branch cross-check); face-embedding pipeline (Cloud Storage, raw-capture deletion, server-tunable threshold); leave accrual scheduler; holiday calendars | +| Web | `06-web-admin-design.md` finalized against real P1 APIs (design only; no SPA build) | +| Data/AI | Pub/Sub event taxonomy frozen (`punch.recorded`, `leave.decided`, `roster.changed`); events flowing to a staging BigQuery dataset | De-risks P3 pipeline | +| Security/Compliance | Play Integrity enforcement on punch endpoints moves from log-only to blocking; speed-of-travel plausibility checks live; kiosk secret provisioning/rotation runbook; face-data DPIA completed | + +**Milestones.** + +| ID | Milestone | Entry depends on | Verified by | +|---|---|---|---| +| P1.M1 | Rosters end-to-end (`GET/PUT /rosters`, roster views, rotation generation jobs, locks) | P0 done | 100k-slice generation load test; lock-override audit test | +| P1.M2 | Regularization loop (request → chain decide → AttendanceDay recompute) + approvals inbox | P1.M1 (shift-aware days) | Recompute ≤ 60 s after approval; chain-permission tests | +| P1.M3 | Kiosk mode (TOTP QR issuance/display/verification, secret provisioning) | P0 punch validation | Replay/expiry/branch-mismatch rejection tests; offline-kiosk drill | +| P1.M4 | Face verification (embedding pipeline, threshold, capture UX) | DPIA approved | FAR/FRR measured on eval set incl. demographic slices | +| P1.M5 | Leave hardening (accrual scheduler, holiday calendars, optional-holiday elections) | P0 leave core | Accrual idempotency re-run test; holiday-aware day math tests | + +**Exit criteria.** A 500-employee, 3-branch pilot tenant runs 4 consecutive weeks where: rosters generate ahead ≥ 28 days with zero manual DB fixes; ≥ 95% of invalid/missed punches are resolved via regularization in-app; kiosk check-in round-trip (scan → server-validated) p95 ≤ 5 s; face match false-accept rate ≤ 0.1% at configured threshold on the eval set; approvals inbox drives all three request types end-to-end; zero P0-regression on the sync contract (regression suite green). + +## 4. P2 — Payroll + +Theme: money. Highest-correctness phase; ships behind per-tenant enablement. + +| Workstream | Deliverables | Notes | +|---|---|---| +| Android | Payroll runs UI for `PAYROLL_ADMIN`/`COMPANY_ADMIN` (run lifecycle DRAFT→CALCULATING→REVIEW→APPROVED→PAID→CLOSED, exception queue review); payslip detail upgrades (PayslipLine breakdown, PDF); document vault (EmployeeDocument upload/view, expiry reminders) | Runs UI on Android per spec §8; full desktop ergonomics arrive with P3 Web Admin | +| Backend | Calculation engine: SalaryComponent evaluation (FIXED/PERCENT_OF_BASIC/PERCENT_OF_GROSS/FORMULA), SalaryStructure/EmployeeSalary effective-dating; run orchestration via per-tenant Cloud Tasks queues (250-employee batches, quarantine on per-employee failure); AttendanceDay/LeaveRequest period integration (workedDays, paidLeaveDays, lopDays, overtimeMinutes); arrears routing for post-lock regularizations; **statutory packs** v1 (versioned, `statutoryCode` binding, launch jurisdictions); payslip PDF rendering to Cloud Storage; approval + segregation-of-duties; payment register / GL exports | +| Web | — (design refinements only) | +| Data/AI | Payroll events into staging BigQuery; reconciliation notebook (run totals vs warehouse) used as release gate | +| Security/Compliance | SoD enforcement tests; payroll audit-trail review (every state transition audit-logged with `totalsJson` snapshot); 7-year retention plumbing for payroll-affecting AuditLog; statutory pack change-control process | + +**Milestones.** + +| ID | Milestone | Entry depends on | Verified by | +|---|---|---|---| +| P2.M1 | Salary configuration (components, structures, EmployeeSalary effective-dating) | P0 done | Overlap-rejection and formula-validation tests | +| P2.M2 | Calculation engine + run orchestration (Cloud Tasks batches, quarantine, progress) | P2.M1, P1.M2 (trustworthy AttendanceDay) | 100k synthetic run ≤ 30 min with forced retries/restarts | +| P2.M3 | Statutory packs v1 (versioned, launch jurisdictions) | P2.M2 | External reviewer sign-off per jurisdiction | +| P2.M4 | Payslips + PDFs + runs UI (lifecycle, exception queue, SoD approve) | P2.M2 | Immutability of CLOSED runs under test; SoD self-approve blocked | +| P2.M5 | Arrears + exports (post-lock corrections → next run; payment register, GL) | P2.M4 | Arrears traceability test; export totals = `totalsJson` | +| P2.M6 | Document vault (upload, expiry reminders, signed URLs) | independent within P2 | Access audit-logged; T-30/T-7 reminder tests | + +**Exit criteria.** Parallel-run gate: for 2 pilot tenants, 2 consecutive months of WorkTrack payroll match the incumbent system to the cent for ≥ 99.5% of payslips, with every mismatch explained and dispositioned. 100k-employee synthetic tenant completes a run ≤ 30 min with zero lost/duplicated payslips across forced task retries and function restarts. CLOSED runs immutable under test. Statutory outputs validated by an external reviewer per launch jurisdiction. + +## 5. P3 — Admin & Analytics + +Theme: desk personas and decision support. + +| Workstream | Deliverables | Notes | +|---|---|---| +| Android | Directory + announcements polish; analytics deep-link handoffs | Light phase for Android | +| Backend | `/analytics/kpis` served from BigQuery; DSR endpoints (export/erasure with pseudonymization); data-residency provisioning (region pinned at tenant creation); org directory search index | +| Web | **Web Admin SPA** (React 18 + TS, Firebase Hosting) implementing `06-web-admin-design.md`: org management, employee lifecycle, policy configuration (leave/shift/holiday), rosters, approvals, payroll console, audit-log explorer, **analytics dashboards**; WCAG 2.1 AA gate (axe-core CI) | Consumes the identical `/v1` API — no privileged endpoints | +| Data/AI | **BigQuery pipeline** production-grade: Firestore export + streaming events, tenant-partitioned datasets, freshness SLO ≤ 24 h (streamed ≤ 5 min); KPI semantic layer; per-tenant cost-attribution tables | +| Security/Compliance | SOC 2 Type I audit readiness (controls per `07-security-architecture.md`); GDPR DSR runbook live; pen test of Web Admin + API | + +**Milestones.** + +| ID | Milestone | Entry depends on | Verified by | +|---|---|---|---| +| P3.M1 | BigQuery pipeline production (export + streaming, partitioned datasets, freshness SLO) | P1 event taxonomy | Freshness monitors ≤ 24 h / ≤ 5 min streamed; reconciliation vs Firestore | +| P3.M2 | Web Admin core (auth, org, employees, policies, rosters, approvals) | API stable through P2 | Task-parity list for `COMPANY_ADMIN`/`HR_ADMIN` | +| P3.M3 | Web Admin payroll console + audit-log explorer | P3.M2, P2 complete | `PAYROLL_ADMIN`/`AUDITOR` task parity; SoD honored in UI | +| P3.M4 | Analytics dashboards + `/analytics/kpis` on BigQuery | P3.M1 | p95 ≤ 3 s per panel at 100k; zero Firestore scans | +| P3.M5 | Compliance surface (DSR endpoints, data residency provisioning) | independent within P3 | DSR export ≤ 72 h automated; region-pinning verified incl. backups | + +**Exit criteria.** Web Admin reaches task-parity for `COMPANY_ADMIN`/`HR_ADMIN`/`PAYROLL_ADMIN`/`AUDITOR` daily jobs (defined task list, 100% completable without Android or support intervention); dashboards serve a 100k-employee tenant with p95 ≤ 3 s per KPI panel and zero Firestore collection scans; DSR export ≤ 72 h automated; axe-core zero critical violations; SOC 2 Type I report issued or scheduled with zero open high findings. + +## 6. P4 — Intelligence & Openness + +Theme: differentiation on top of a trusted data asset. + +| Workstream | Deliverables | Notes | +|---|---|---| +| Android | Insight surfaces (manager nudges: absenteeism risk, OT anomaly) with explanation + confidence UI | Advisory-only presentation | +| Backend | **Open API** program: published OpenAPI spec, scoped API keys (reusing `resource:action` permissions), partner rate tiers; **webhooks** (HMAC-signed, ≥ 3 retries + DLQ, secret rotation); SSO (OIDC/SAML) + SCIM provisioning | +| Web | Insights dashboards; webhook/API-key management console; insight feedback capture (accept/dismiss) for model improvement | +| Data/AI | **AI insights**: absenteeism-risk and attrition-signal models, overtime/punch **anomaly detection**; feature pipelines in BigQuery; per-tenant opt-out; model cards + monitoring (drift, calibration); `GET /analytics/insights` serving layer | +| Security/Compliance | AI governance: human-review requirement (no automated adverse action), bias evaluation across branches/departments, DPIA for profiling; webhook/API-key abuse monitoring; SOC 2 Type II period underway | + +**Milestones.** + +| ID | Milestone | Entry depends on | Verified by | +|---|---|---|---| +| P4.M1 | Open API program (OpenAPI spec, scoped API keys, partner rate tiers) | P0–P3 API stability | External team builds an integration from docs alone | +| P4.M2 | Webhooks (HMAC signing, retries + DLQ, secret rotation, console) | P4.M1 | Delivery ≥ 99.5% within 5 min; replay-from-DLQ drill | +| P4.M3 | SSO (OIDC/SAML) + SCIM | independent within P4 | Certification against two major IdPs; deprovision ≤ 5 min | +| P4.M4 | AI feature pipelines + models (absenteeism, attrition, OT/punch anomaly) | P3.M1 warehouse | Held-out AUC ≥ 0.75 vs baseline; bias evaluation passed | +| P4.M5 | Insight serving + surfaces (`GET /analytics/insights`, manager UI, feedback loop) | P4.M4 | 100% explanation coverage; opt-out honored; no automated adverse action | + +**Exit criteria.** Insights beat naive baselines on held-out data (e.g. absenteeism risk AUC ≥ 0.75 vs seasonal baseline) and are live for opt-in tenants with explanation coverage of 100% of surfaced insights; webhook delivery success ≥ 99.5% within 5 min (excluding endpoint-down); at least 2 external integrations built on the open API by a non-WorkTrack team using published docs alone; SSO/SCIM certified against two major IdPs. + +--- + +## 7. Cross-phase milestone dependencies + +```mermaid +graph TD + P0D["P0 done
(§2.1 definition of done)"] + P0D --> P1M1["P1.M1 Rosters"] + P0D --> P1M3["P1.M3 Kiosk"] + P0D --> P1M5["P1.M5 Leave hardening"] + P1M1 --> P1M2["P1.M2 Regularization + approvals inbox"] + DPIA["DPIA approved"] --> P1M4["P1.M4 Face verification"] + P0D --> P2M1["P2.M1 Salary config"] + P1M2 --> P2M2["P2.M2 Calc engine + orchestration"] + P2M1 --> P2M2 + P2M2 --> P2M3["P2.M3 Statutory packs"] + P2M2 --> P2M4["P2.M4 Payslips + runs UI"] + P2M4 --> P2M5["P2.M5 Arrears + exports"] + EVT["P1 event taxonomy"] --> P3M1["P3.M1 BigQuery pipeline"] + P2M5 --> P3M3["P3.M3 Web payroll console"] + P3M2["P3.M2 Web Admin core"] --> P3M3 + P3M1 --> P3M4["P3.M4 Dashboards"] + P3M1 --> P4M4["P4.M4 AI models"] + P4M1["P4.M1 Open API"] --> P4M2["P4.M2 Webhooks"] + P4M4 --> P4M5["P4.M5 Insight serving"] +``` + +The critical path is P0 → P1.M1 → P1.M2 → P2.M2 → P2.M4 → P2.M5 → P3.M3: everything payroll-trustworthy depends on shift-aware, correctable attendance. Kiosk (P1.M3), face (P1.M4), document vault (P2.M6), the BigQuery pipeline (P3.M1), and SSO/SCIM (P4.M3) are off-critical-path and absorb schedule slack. + +## 8. Tenant rollout playbook (per phase) + +| Stage | Scope | Gate to next stage | +|---|---|---| +| Internal dogfood | WorkTrack's own tenant on staging-parity prod config | Feature-complete, exit-criteria suites green | +| Design partners | 2–3 tenants, feature-flagged, weekly feedback loop | 4 weeks stable; pilot metrics met (phase exit criteria) | +| Early access | Opt-in tenants, self-serve enablement | Support load ≤ 5 tickets/1k employees/month; SLOs held | +| General availability | Flag default-on for new tenants; migration comms for existing | Phase gate review recorded (§12) | + +Payroll (P2) adds a mandatory parallel-run stage between design partners and early access for every tenant, regardless of size: one full cycle matched against the incumbent before WorkTrack becomes the paying system. + +## 9. Team shape suggestion + +| Role | P0–P1 | P2 | P3 | P4 | Notes | +|---|---|---|---|---|---| +| Android engineers | 3 | 2 | 1 | 2 | Peak early: foundation, sync, kiosk, approvals | +| Backend (TS) engineers | 3 | 4 | 3 | 3 | Peak in P2: payroll engine + statutory packs | +| Web engineers | 0 | 1 (prep) | 3 | 2 | SPA is P3; one engineer starts scaffolding late P2 | +| Data engineer | 0.5 | 1 | 2 | 2 | Event taxonomy from P1; pipeline in P3 | +| ML engineer | 0 | 0 | 0.5 | 2 | Joins late P3 for feature pipelines | +| QA / SDET | 1 | 2 | 2 | 2 | Payroll parallel-run automation is a dedicated effort | +| Security engineer | 0.5 | 1 | 1 | 1 | Shared → dedicated from P2 (SoD, SOC 2, pen test) | +| Product manager | 1 | 1 | 1.5 | 1.5 | Second PM (part-time) for Web Admin + platform/API | +| Engineering manager / TL | 1 | 1 | 1.5 | 1.5 | | +| **Total (approx.)** | **10** | **13** | **15.5** | **17** | | + +Structure: one durable **platform pod** (API core, sync, infra, security) and per-phase **feature pods** (scheduling, payroll, web/analytics, AI). Statutory pack authoring pairs backend engineers with contracted per-jurisdiction payroll domain experts — do not staff this as pure engineering. + +## 10. Risk register (top 10) + +| # | Risk | Likelihood | Impact | Mitigation | Owner | +|---|---|---|---|---|---| +| R1 | Payroll miscalculation damages trust irreparably | Medium | Critical | P2 parallel-run gate (2 months, ≥ 99.5% match-to-the-cent); per-employee quarantine instead of silent failure; immutable CLOSED runs; statutory pack versioning + external review | Backend lead | +| R2 | Firestore hot-spots / cost blowout at 100k-employee tenants | Medium | High | Sharded counters, projection reads, BigQuery offload, per-tenant cost attribution with alerts (arch doc §6); 100k synthetic-tenant load test as a standing release gate from P1 | Platform pod | +| R3 | Punch spoofing (mock GPS, rooted devices, replayed kiosk QR) undermines the core product claim | High | High | Device binding + Play Integrity blocking from P1; TOTP window + HMAC + branch cross-check; speed-of-travel checks; monitored spoof-attempt metrics; bug-bounty scope | Security eng | +| R4 | Sync-contract bugs cause silent data loss in the field | Medium | Critical | Append-only punches; idempotency ledger; per-item push results; "no silent loss" is a tested invariant (chaos suite: kill app/network mid-sync); rejection → actionable notification | Android lead | +| R5 | Statutory packs wrong or stale per jurisdiction | High | High | Versioned packs with change control; jurisdiction launch checklist incl. external validation; runs record pack version; disclaimed generic mode outside supported jurisdictions | PM + Backend | +| R6 | Web Admin (P3) slips, blocking enterprise deals | Medium | High | Design (`06-web-admin-design.md`) finalized in P1 against real APIs; API hardened by P2 so SPA work is UI-only; scaffolding starts late P2; task-parity exit list fixed up front | Web lead | +| R7 | Face verification: bias, false accepts/rejects, privacy backlash | Medium | High | Embeddings-only storage + raw-capture deletion + CMEK option; server-tunable threshold; per-tenant opt-in; DPIA in P1; measured FAR/FRR across demographic slices before enable | Security eng + PM | +| R8 | Cloud Functions cold starts break punch latency SLO at scale | Medium | Medium | min-instances on punch/sync functions; latency SLO monitoring from P0; preserved Cloud Run migration path (ADR-006) with rehearsed cutover | Platform pod | +| R9 | Compliance gaps (GDPR DSR, residency, SOC 2) discovered late by enterprise procurement | Medium | High | Security/Compliance workstream runs every phase; DSR + residency land in P3 before enterprise GA; SOC 2 Type I in P3, Type II period in P4; control mapping maintained in `07-security-architecture.md` | Security eng | +| R10 | AI insights (P4) produce unfair or unexplained adverse signals about employees | Medium | High | Advisory-only + human review (no automated adverse action); explanation + confidence mandatory; per-tenant opt-out; bias evaluation and model cards as release gates | ML eng + PM | + +## 11. Release & versioning strategy + +- **Trains.** Backend deploys continuously behind phase-gated feature flags (per-tenant enablement for payroll and face verification); Android ships a fortnightly train via Play staged rollout (1% → 10% → 50% → 100% with sync-health beacon monitoring at each step); Web Admin (from P3) deploys continuously to Firebase Hosting with preview channels per PR. +- **API compatibility.** `/v1` evolves additively only (master spec §3.4); the server supports the two previous Android train versions at all times; any breaking need opens a `/v2` discussion with an explicit ≥ 180-day deprecation window — no in-place breaks. +- **Feature flags.** Per-tenant flags gate P1+ features (kiosk, face, payroll, insights); flags are config on the Company document (`settingsJson`), read server-side; a flag removed only after two stable releases at 100%. +- **Data migrations.** Firestore schema changes are additive with lazy backfill jobs (Cloud Tasks batched, resumable); no release may require a stop-the-world migration; every backfill is idempotent and progress-checkpointed. +- **Rollback.** Backend: redeploy previous tag (no destructive migrations, so always safe). Android: halt staged rollout + server-side flag off; the offline outbox contract guarantees no data loss across app downgrades because queued ops target the stable `/v1` surface. + +## 12. Roadmap governance + +- **Phase gates.** A phase exits only when its exit criteria are demonstrably met; exit reviews are recorded and the master spec §8 is amended first if scope moves (per the spec's precedence rule). +- **Regression floor.** Every phase re-runs the P0 verification checklist (§2.2) plus prior phases' exit-criterion test suites; the sync contract and payroll parallel-run harness are permanent CI fixtures once introduced. +- **Change control.** Scope changes route through `00-master-spec.md` (canonical, update-first), then this roadmap, then the affected design docs — never the reverse. +- **Standing gates from P1 onward.** 100k synthetic-tenant load test; SLO burn-rate review (NFR-AVL/LAT budgets in `01-product-requirements.md` §6); DLQ-depth-zero check across all queues before release. +- **Risk review.** The register in §10 is reviewed at each phase gate; any risk trending to "High/Critical realized" freezes feature work in the owning workstream until a mitigation lands. diff --git a/docs/10-localization-afghanistan.md b/docs/10-localization-afghanistan.md new file mode 100644 index 0000000..5885304 --- /dev/null +++ b/docs/10-localization-afghanistan.md @@ -0,0 +1,97 @@ +# WorkTrack — Afghanistan Localization Architecture (دری / پښتو) + +Version: 1.0 · Status: Approved · Owners: Platform Architecture + +WorkTrack is built **for Afghanistan and Afghan organizations**. Dari (دری) and +Pashto (پښتو) are first-class product languages — not translations bolted onto an +English app — and the platform's business calendar is the **Solar Hijri (هجری شمسی)** +calendar. This document specifies how that is implemented across the Android app, +backend, and (future) web admin. + +--- + +## 1. Language policy + +| Locale | Role | +|---|---| +| `fa-AF` (Dari) | **Default.** The base `values/` resources are Dari; any unmatched device locale falls back to Dari. | +| `ps-AF` (Pashto) | Full translation (`values-ps/`). | +| `en` | Full translation (`values-en/`) for foreign managers/auditors. | + +- Every user-visible string lives in per-module resources with a module prefix + (`ds_`, `auth_`, `dash_`, `att_`, `leave_`, `pay_`, `prof_`, `nav_`) so library + resource merging can never silently collide. +- The brand name "WorkTrack" stays in Latin script in all languages. +- `android:localeConfig` (`app/src/main/res/xml/locales_config.xml`) surfaces the + per-app language setting on Android 13+; the in-app picker in **Profile → زبان** + uses `AppCompatDelegate.setApplicationLocales` and works on every supported API + level (`MainActivity` extends `AppCompatActivity` for exactly this reason). + +## 2. Error and message localization + +- Screen strings resolve via `stringResource` per module. +- Domain/server failures travel as typed `AppError` values (never pre-rendered + strings). ViewModels emit `AppError` in state/effects; the UI renders it with + `AppError.localizedMessage()` (`core:designsystem/l10n/ErrorMessages.kt`), which + maps stable business codes (`GEOFENCE_VIOLATION`, `INSUFFICIENT_LEAVE_BALANCE`, + `KIOSK_TOKEN_INVALID`, …) to Dari/Pashto/English text. +- Field-level validation surfaces as **field keys**; each screen maps keys to its + own localized messages, so no English validation text leaks from the domain layer. + +## 3. Calendar: Solar Hijri everywhere + +- `core:common/time/SolarHijri.kt` implements Gregorian ⇄ Solar Hijri conversion + (ported from the jalaali-js break-year algorithm, unit-tested incl. round trips + and leap years). Afghanistan shares the Iranian leap structure; only month names + differ. +- Afghan month names ship as localized string arrays: Dari **حمل ثور جوزا سرطان اسد + سنبله میزان عقرب قوس جدی دلو حوت**, Pashto **وری غويی غبرګولی چنګاښ زمری وږی تله + لړم ليندۍ مرغومی سلواغه کب**, English transliterations for the `en` locale. +- Display formatting is centralized in `core:designsystem/l10n/AfghanFormat.kt`: + dates, ranges, month headers, and timestamps all render in Shamsi with + Extended Arabic-Indic digits (۰–۹) for Dari/Pashto. +- **Attendance history pages by Shamsi month** (e.g. سرطان ۱۴۰۵): the ViewModel + converts the Shamsi month to a Gregorian date range for the Room query. +- **Payroll periods are Shamsi months**: `Payslip.periodYear/periodMonth` carry + Solar Hijri values (1405/4 = سرطان ۱۴۰۵). Tenant provisioning and the payroll + engine (P2) must create runs per Shamsi month. +- Storage stays Gregorian/epoch-based (Room, Firestore, API ISO-8601): the + conversion happens only at the display and query-boundary layers, which keeps + interop, indexes, and delta cursors calendar-agnostic. + +## 4. RTL, digits, typography + +- `supportsRtl` + Compose's locale-driven `LayoutDirection` mirror every screen; + directional icons use the `AutoMirrored` icon set. +- Digits: Latin digits are converted to ۰–۹ at display time (`AfghanDigits`) for + `fa`/`ps`. Data entry and storage remain ASCII. +- System fonts cover Arabic-script Dari/Pashto (incl. ګ ډ ړ ږ ۍ ...). A custom + Vazirmatn/Noto Naskh bundle is a P1 polish item. + +## 5. Afghanistan business rules + +| Rule | Where | +|---|---| +| Weekend = **Friday** (جمعه) | Server holiday calendars mark Friday `WEEK_OFF`; leave settlement excludes Fridays and public holidays on approval (client shows an estimate note). | +| Public holidays (Nawruz, Eid al-Fitr, Eid al-Adha, Ashura, Independence Day…) | Tenant `HolidayCalendar` seeded per year; Eid dates are lunar and entered per-tenant annually. | +| Currency | Default `AFN` (؋); stored per company, formatted with localized digits. | +| Timezone | Default `Asia/Kabul` (UTC+4:30) per company/branch. | +| Payroll | Runs per Solar Hijri month (§3). | + +## 6. Testing & workflow + +- `SolarHijriTest` covers Nawruz boundaries, leap years (1403 leap / 1404–05 not), + month lengths, and 730-day round trips. +- Translation source of truth is the resource files; new strings must land in all + three locales in the same PR (enforceable via `lint missingTranslation` once the + default-locale declaration `tools:locale="fa"` is added in a lint pass). +- Web admin (P3) reuses the same message catalogs via exported JSON. + +## 7. Known gaps (tracked for P1) + +- Material date picker still renders a Gregorian grid; a native Shamsi picker + component is a P1 deliverable (selected dates already display in Shamsi). +- Server-generated `detail` strings inside RFC 7807 problems are English; clients + render localized text by `code`, so this only affects debugging surfaces. +- Pashto plural forms are simplified (Android quantity strings to be adopted with + the l10n lint pass). diff --git a/docs/11-local-demo-setup.md b/docs/11-local-demo-setup.md new file mode 100644 index 0000000..c25e525 --- /dev/null +++ b/docs/11-local-demo-setup.md @@ -0,0 +1,178 @@ +# راه‌اندازی محلی با داده نمونه (Local Demo Setup) + +این راهنما نشان می‌دهد چطور **بدون پروژهٔ واقعی Firebase و بدون هیچ هزینه‌ای**، کل +پلتفرم را روی کمپیوتر خودتان با داده نمونهٔ افغانی اجرا کنید — هم پورتال وب مدیر و +هم اپ اندروید. + +همه‌چیز با **Firebase Emulator Suite** (محلی) کار می‌کند. + +> English speakers: this is a step-by-step guide to run the whole platform locally +> against the Firebase Emulator Suite with a seeded Afghan demo tenant — no real +> Firebase project or billing required. Commands are the same regardless of language. + +--- + +## پیش‌نیازها + +- **Node.js 20+** و **npm** +- **Firebase CLI**: `npm install -g firebase-tools` +- **Java** (برای emulator فایرستور لازم است — از https://adoptium.net نصب کنید) + +--- + +## ⭐ ساده‌ترین راه: یک دستور + +به‌جای همهٔ قدم‌های پایین، فقط این را اجرا کنید: + +```zsh +cd ~/StudioProjects/WorkTrack +bash run-demo.sh +``` + +این اسکریپت خودش **همه‌چیز را به ترتیب درست** انجام می‌دهد: build بک‌اند، روشن +کردن emulator، وارد کردن داده نمونه، و اجرای پورتال — همه در **یک ترمینال**. +صبر کنید تا آدرس `http://localhost:...` چاپ شود، آن را در مرورگر باز کنید و با +`admin@worktrack.af` / `Passw0rd!` وارد شوید. + +برای **توقف**: یک بار **Ctrl+C** بزنید (همه‌چیز با هم بسته می‌شود). + +> اگر خطای «port in use» دیدید، یعنی یک emulator قدیمی هنوز باز است — همهٔ +> ترمینال‌های قبلی را ببندید و دوباره اجرا کنید. + +قدم‌های دستی پایین فقط برای وقتی است که بخواهید هر بخش را جدا اجرا کنید. + +--- + +--- + +## قدم ۱ — بک‌اند را بسازید و emulator را روشن کنید + +```zsh +cd ~/StudioProjects/WorkTrack/backend/functions +npm install +``` + +یک فایل کوچک برای راز kiosk بسازید (تا emulator شکایت نکند): + +```zsh +echo 'KIOSK_HMAC_SECRET=demo-secret' > .secret.local +``` + +حالا emulator را روشن کنید. **از `npm run serve` استفاده کنید** — این دستور اول +کد TypeScript را build می‌کند و بعد emulator را با پروژهٔ `demo-worktrack` و +فایل تنظیمات درست اجرا می‌کند (build کردن الزامی است، وگرنه تابع `api` بارگذاری +نمی‌شود): + +```zsh +cd ~/StudioProjects/WorkTrack/backend/functions +npm run serve +``` + +این ترمینال را **باز بگذارید**. باید یک جدول با آدرس تابع `api` ببینید و در آخر +خط **`All emulators ready!`** (Functions روی `5001`، Firestore روی `8080`، +Auth روی `9099`). + +> اگر Firebase CLI از شما login خواست، `firebase login` را اجرا کنید. برای emulator +> نیازی به پروژهٔ واقعی نیست — پیشوند `demo-` یعنی هیچ منبع واقعی ساخته نمی‌شود. + +--- + +## قدم ۲ — داده نمونه را وارد کنید (Seed) + +یک ترمینال **جدید** باز کنید (emulator باید در حال اجرا بماند): + +```zsh +cd ~/StudioProjects/WorkTrack/backend/functions +npm run seed +``` + +باید پیام موفقیت و لیست حساب‌ها را ببینید. این اسکریپت می‌سازد: + +- شرکت **«شرکت ساختمانی کابل»** با دفتر مرکزی در کابل (با geofence) +- **۷ کارمند** (شامل مدیر) +- **حاضری ۷ روز** (حاضر، ناوقت، غیرحاضر، نیم‌روز، جمعه تعطیل) +- **۳ درخواست رخصتی در انتظار** برای تست تاییدی +- انواع رخصتی، بیلانس، اعلانات، اجزای معاش (به افغانی) + +### حساب‌های ورود (رمز همه: `Passw0rd!`) + +| ایمیل | نقش | برای | +|---|---|---| +| `admin@worktrack.af` | COMPANY_ADMIN | پورتال وب | +| `hr@worktrack.af` | HR_ADMIN | پورتال وب | +| `ahmad@worktrack.af` | EMPLOYEE | اپ اندروید | + +--- + +## قدم ۳ — پورتال وب را اجرا کنید + +یک ترمینال جدید: + +```zsh +cd ~/StudioProjects/WorkTrack/web +npm install +cp .env.emulator .env.local +npm run dev +``` + +مرورگر را روی آدرسی که نشان می‌دهد باز کنید (مثلاً `http://localhost:5173/`). + +حالا با **`admin@worktrack.af`** و رمز **`Passw0rd!`** وارد شوید. باید ببینید: + +- **داشبورد**: آمار امروز (حاضر، غیرحاضر، ناوقت…) و نمودار روند ۷ روزهٔ شمسی +- **کارمندان**: لیست ۷ کارمند، جستجو، فرم افزودن +- **حاضری**: تختهٔ زندهٔ روزانه با وضعیت هر کارمند +- **رخصتی‌ها**: ۳ درخواست در انتظار — تایید یا رد کنید + +کلید بالای صفحه زبان را بین **دری / پښتو / English** عوض می‌کند. + +--- + +## قدم ۴ (اختیاری) — اپ اندروید را با همین داده اجرا کنید + +اپ اندروید (نسخهٔ **debug**) خودش به همین emulator محلی وصل می‌شود — **نیازی به +`google-services.json` نیست.** فقط باید emulator (همان ترمینال `run-demo.sh`) در +حال اجرا باشد. + +1. پروژه را در **Android Studio** باز کنید و `git pull` کنید (تا آخرین تغییرات را بگیرید). +2. اپ را روی **امولیتور اندروید (AVD)** اجرا کنید — دکمهٔ ▶ Run. +3. با این حساب وارد شوید: + - ایمیل: **`ahmad@worktrack.af`** + - رمز: **`Passw0rd!`** + +حالا داشبورد کارمند «احمد کریمی» را می‌بینید: بیلانس رخصتی، اعلانات، و در بخش +**تاریخچهٔ حاضری** ۷ روز حاضری (به تقویم شمسی). + +> اپ debug از طریق `10.0.2.2` (که آدرس کمپیوتر شما از داخل امولیتور است) به +> Auth روی `9099` و Functions روی `5001` وصل می‌شود. این فقط روی **امولیتور** +> اندروید کار می‌کند، نه گوشی واقعی. + +### حاضری با GPS (اختیاری) + +geofence روی **کابل** تنظیم شده، پس برای اینکه «ثبت ورود» کار کند باید موقعیت +امولیتور را به کابل بگذارید: + +- کنار پنجرهٔ امولیتور روی **`•••`** (Extended controls) کلیک کنید +- **Location** → مقدار **Latitude: `34.5553`** و **Longitude: `69.2075`** → **Set Location** + +بعد در اپ «ثبت ورود» بزنید — چون داخل ساحهٔ کاری هستید، حاضری ثبت و همگام می‌شود. + +--- + +## توقف و ری‌ست + +- برای توقف: در هر ترمینال **Ctrl+C** بزنید. +- داده emulator در حافظه است و با توقف پاک می‌شود. برای داده تازه، دوباره + `npm run seed` را (وقتی emulator روشن است) اجرا کنید. + +--- + +## مشکلات رایج + +| مشکل | راه حل | +|---|---| +| پورتال «تنظیمات Firebase کامل نیست» نشان می‌دهد | `.env.local` را از `.env.emulator` کپی کرده‌اید؟ سرور dev را دوباره اجرا کنید. | +| ورود کار نمی‌کند / خطای شبکه | emulator روشن است؟ آیا `npm run seed` را اجرا کردید؟ | +| داشبورد خالی است | seed را دوباره اجرا کنید؛ مطمئن شوید project id در هر دو `demo-worktrack` است. | +| `firebase: command not found` | `npm install -g firebase-tools` | +| خطای Java در Firestore emulator | `brew install openjdk` (مک) | diff --git a/docs/12-production-deployment.md b/docs/12-production-deployment.md new file mode 100644 index 0000000..44d2b6a --- /dev/null +++ b/docs/12-production-deployment.md @@ -0,0 +1,154 @@ +# رفتن به Production (نصب واقعی) + +این راهنما نشان می‌دهد چطور WorkTrack را از حالت محلی به یک **پروژهٔ واقعی Firebase** +ببرید تا شرکت‌های واقعی بتوانند استفاده کنند. ساده و مرحله‌به‌مرحله. + +> English: step-by-step guide to deploy WorkTrack to a real Firebase project. + +--- + +## دو محصول (نسخهٔ شرکت / نسخهٔ کارمند) + +WorkTrack دو نسخه دارد که هر دو از یک بک‌اند استفاده می‌کنند: + +| محصول | برای چه کسی | چیست | +|---|---|---| +| **پورتال شرکت** (`web/`) | مدیر، منابع بشری، معاش | وب‌سایت مدیریت — داشبورد، کارمندان، حاضری، رخصتی، معاش | +| **اپ کارمند** (`app/`) | کارمندان | اپ اندروید — حاضری، رخصتی، فیش معاش | + +هر شرکت **خودش در پورتال ثبت‌نام می‌کند** و فضای کاری جدا و امن خودش را می‌گیرد +(multi-tenant). داده هیچ شرکتی برای شرکت دیگر دیده نمی‌شود. + +--- + +## پیش‌نیازها + +- یک حساب Google +- **Firebase CLI**: `npm install -g firebase-tools` سپس `firebase login` +- برای Cloud Functions، پروژه باید روی پلن **Blaze** باشد (پرداخت به‌اندازهٔ مصرف؛ + استفادهٔ کم معمولاً رایگان است) + +--- + +## قدم ۱ — ساخت پروژهٔ Firebase + +1. به بروید و **Add project** را بزنید + (مثلاً نام: `worktrack-prod`). +2. در **Build → Authentication → Sign-in method**، گزینهٔ **Email/Password** را + **Enable** کنید. +3. در **Build → Firestore Database**، یک دیتابیس بسازید (production mode). +4. پروژه را به پلن **Blaze** ارتقا دهید (منوی پایین چپ، Upgrade). + +پروژه را به مخزن وصل کنید: + +```zsh +cd ~/StudioProjects/WorkTrack/backend +cp .firebaserc.example .firebaserc +``` +بعد داخل `.firebaserc`، به‌جای `worktrack-prod` شناسهٔ واقعی پروژهٔ خود را بگذارید. + +--- + +## قدم ۲ — استقرار بک‌اند (API + قوانین + ایندکس) + +```zsh +cd ~/StudioProjects/WorkTrack/backend/functions +npm install +npm run build + +# راز kiosk را در Secret Manager بگذارید (یک بار): +firebase functions:secrets:set KIOSK_HMAC_SECRET + +cd ~/StudioProjects/WorkTrack/backend +firebase deploy --only functions,firestore:rules,firestore:indexes --project +``` + +بعد از استقرار، آدرس تابع `api` را یادداشت کنید — چیزی مثل: +`https://us-central1-.cloudfunctions.net/api` + +--- + +## قدم ۳ — استقرار پورتال شرکت (وب) + +1. در Firebase console → **Project settings → General → Your apps** یک اپ **Web** + بسازید و مقادیر config آن را بردارید. +2. یک فایل `web/.env.production` بسازید: + +``` +VITE_API_BASE_URL=/v1 +VITE_FIREBASE_API_KEY= +VITE_FIREBASE_AUTH_DOMAIN=.firebaseapp.com +VITE_FIREBASE_PROJECT_ID= +VITE_FIREBASE_APP_ID= +``` + +> `VITE_API_BASE_URL=/v1` کار می‌کند چون Hosting درخواست‌های `/v1/**` را به تابع +> `api` هدایت می‌کند (در `backend/firebase.json` تنظیم شده) — بدون مشکل CORS. + +3. build و deploy: + +```zsh +cd ~/StudioProjects/WorkTrack/web +npm install +npm run build +cd ~/StudioProjects/WorkTrack/backend +firebase deploy --only hosting --project +``` + +پورتال حالا روی `https://.web.app` در دسترس است. + +--- + +## قدم ۴ — اولین شرکت را ثبت‌نام کنید + +نیازی به اسکریپت seed نیست! به پورتال بروید، روی **«شرکت جدید؟ ثبت‌نام کنید»** +کلیک کنید و فرم را پر کنید (نام شرکت، نام مدیر، ایمیل، رمز). فضای کاری شرکت، +شعبهٔ «دفتر مرکزی»، انواع رخصتی پیش‌فرض و حساب مدیر به‌صورت خودکار ساخته می‌شود. +بعد وارد شوید و کارمندان را اضافه کنید. + +--- + +## قدم ۵ — اپ کارمند (اندروید) + +1. در Firebase console → **Add app → Android**: + - Package name برای نسخهٔ عرضه: **`app.worktrack`** + - (برای تست، `app.worktrack.debug` را هم اضافه کنید) +2. فایل **`google-services.json`** را دانلود و در پوشهٔ **`app/`** بگذارید. +3. آدرس API نسخهٔ عرضه از قبل روی `https://api.worktrack.app/v1/` است؛ اگر دامنهٔ + دلخواه ندارید، در `app/build.gradle.kts` (بخش `defaultConfig`) آن را به آدرس + تابع خود تغییر دهید: + `https://us-central1-.cloudfunctions.net/api/v1/` +4. در Android Studio: **Build → Generate Signed Bundle / APK** → یک keystore + بسازید → **release** → فایل `.aab` را بسازید. +5. `.aab` را در **Google Play Console** آپلود کنید. + +> نسخهٔ **release** به Firebase واقعی وصل می‌شود (نه emulator). نسخهٔ **debug** +> برای تست به emulator محلی وصل می‌ماند. + +--- + +## قدم ۶ — کارهای امنیتی پیش از عرضهٔ عمومی + +این‌ها را قبل از باز کردن ثبت‌نام عمومی انجام دهید (در `docs/07` مفصل آمده): + +- **تأیید ایمیل** برای ثبت‌نام شرکت (جلوگیری از حساب‌های جعلی) +- **محدودسازی نرخ** (rate limiting) روی `POST /v1/public/signup` و ورود +- **App Check** برای اپ اندروید و پورتال وب +- مرور **قوانین Firestore** و **کاتالوگ دسترسی‌ها** (RBAC) +- **بکاپ** خودکار Firestore و سیاست نگه‌داری داده + +--- + +## خلاصهٔ دستورها + +```zsh +# بک‌اند +cd backend/functions && npm run build +cd backend && firebase deploy --only functions,firestore --project + +# پورتال وب +cd web && npm run build +cd backend && firebase deploy --only hosting --project + +# اپ کارمند: Android Studio → Signed Bundle → Play Console +``` diff --git a/docs/13-operations-runbook.md b/docs/13-operations-runbook.md new file mode 100644 index 0000000..66d67ed --- /dev/null +++ b/docs/13-operations-runbook.md @@ -0,0 +1,187 @@ +# راهنمای بهره‌برداری (وقتی چیزی درست کار نمی‌کند) + +سند ۱۲ می‌گوید چطور سیستم را **راه بیندازید**. این سند می‌گوید وقتی راه افتاده و +چیزی خراب شد، چه کار کنید. + +بیشتر این نوشته از یک اتفاق واقعی درآمده: یک ایندکس گم‌شدهٔ Firestore باعث شد +هفته‌ها حاضری هیچ‌کس در پورتال نمایش داده نشود. پانچ‌ها همه ثبت می‌شدند، خطا هم در +لاگ بود، ولی تنها نشانهٔ قابل‌مشاهده این بود که «همه غیرحاضرند». نردبان زیر همان +مسیری است که در نهایت به جواب رسید. + +--- + +## حاضری در پورتال نمایش داده نمی‌شود + +از بالا به پایین بروید. هر پله را تمام کنید و بعد پلهٔ بعد. + +### ۱. تاریخ درست را نگاه می‌کنید؟ + +حاضری با **تقویم شرکت** ثبت می‌شود، نه ساعت مرورگر شما. اگر بیرون از افغانستان +هستید، «امروز» شما با «امروز» کابل فرق دارد — اوتاوا و کابل ۸.۵ ساعت فاصله دارند، +یعنی بعدازظهر اوتاوا از قبل فردای کابل است. + +پورتال حالا خودش این را درست می‌کند و اگر روزتان با روز شرکت فرق داشته باشد نشان +«به وقت شرکت» بالای صفحهٔ حاضری ظاهر می‌شود. اگر آن نشان را می‌بینید، تاریخ +انتخاب‌شده همان روز کابل است. + +### ۲. پورتال چه می‌گوید؟ + +اگر کارمند در جدول هست ولی ساعتش خالی است، **دلیلش روی همان سطر نوشته شده**: + +| چیپ | معنی | +|---|---| +| «خارج از محدودهٔ کاری» | پانچ بیرون geofence بوده و در ساعت کاری حساب نشده | +| «ساعت دستگاه نادرست است» | ساعت گوشی بیش از ۱۰ دقیقه با سرور فاصله داشته | +| «جابه‌جایی غیرممکن» | فاصلهٔ دو پانچ پشت‌سرهم با سرعتی بیش از ۲۵۰ کیلومتر بر ساعت | +| «خیلی دیر ارسال شد» | پانچ قدیمی‌تر از ۷ روز به سرور رسیده | +| «نیاز به بررسی» | تأیید چهره انجام نشده در شرکتی که آن را روشن کرده | + +اگر کارمند **اصلاً در جدول نیست**، وضعیت پرسنلی‌اش را ببینید؛ کسی که ACTIVE نباشد +ولی پانچ داشته باشد با نشان «غیرفعال» می‌آید. + +### ۳. آیا پانچ به سرور رسیده؟ + +اپ اندروید پانچ را اول محلی ثبت می‌کند و بعد همگام‌سازی می‌کند. پس ممکن است +کارمند «ثبت شد» ببیند در حالی که هنوز چیزی به سرور نرسیده. + +```bash +firebase functions:log --only api --project worktrack-prod -n 100 | grep -i punch +``` + +### ۴. آیا محاسبهٔ روز شکست خورده؟ (همان ایراد اصلی) + +```bash +firebase functions:log --only api --project worktrack-prod -n 200 | grep -i "FAILED_PRECONDITION\|requires an index" +``` + +اگر چیزی پیدا شد، یک ایندکس لازم است. لینک ساخت ایندکس معمولاً داخل خود پیام خطا +هست. بعد از افزودنش به `backend/firestore.indexes.json`: + +```bash +firebase deploy --only firestore:indexes --project worktrack-prod +``` + +**نکتهٔ مهم:** ایندکس، روزهای گذشته را خودبه‌خود درست نمی‌کند. سراغ backfill بروید. + +--- + +## ترمیم روزهای گم‌شده (backfill) + +روزهای حاضری از روی پانچ‌ها ساخته می‌شوند، ولی فقط وقتی پانچ تازه‌ای برسد. اگر +دوره‌ای محاسبه شکست خورده، آن روزها تا ابد خالی می‌مانند مگر اینکه بازسازی شوند. + +```bash +cd backend/functions && npm run build + +# اول فقط گزارش می‌گیرد و هیچ نمی‌نویسد — همیشه از این شروع کنید +GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/backfill-attendance.js + +# اگر تعداد روزها منطقی بود: +GOOGLE_CLOUD_PROJECT=worktrack-prod node lib/scripts/backfill-attendance.js --apply +``` + +نیاز به اعتبارنامه دارد: `gcloud auth application-default login`. + +اسکریپت همان تابعی را صدا می‌زند که سرور استفاده می‌کند، پس روز بازسازی‌شده با روزی +که یک پانچ زنده می‌سازد یکسان است. تکرارش هم بی‌خطر است. + +گزینه‌ها: `--company=` برای یک شرکت، `--from=` و `--to=` برای بازهٔ تاریخ، و `--all` +که روزهای موجود را هم دوباره محاسبه می‌کند (پیش‌فرض فقط گم‌شده‌ها). + +--- + +## پایش خودکار + +هر شب ساعت ۰۲:۰۰ به وقت کابل، تابع `attendanceIntegrityAudit` بررسی می‌کند که هر +کارمندی که پانچ دارد، روز حاضری‌اش هم ساخته شده و از پانچ‌هایش کهنه‌تر نباشد. + +- یافته‌ها با رشتهٔ `ATTENDANCE_INTEGRITY` در لاگ ثبت و در مجموعهٔ + `integrityReports` نوشته می‌شوند. +- یک سیاست هشدار به `aminhashemi979@gmail.com` ایمیل می‌زند. خود ایمیل راهنمای + رفع مشکل را دارد. + +اگر چنین ایمیلی گرفتید، از پلهٔ ۴ نردبان بالا شروع کنید. + +دیدن آخرین اجرا: + +```bash +firebase functions:log --only attendanceIntegrityAudit --project worktrack-prod +``` + +بازسازی هشدار روی پروژهٔ دیگر یا بعد از تغییر گیرنده: + +```bash +bash backend/monitoring/setup-alerts.sh you@example.com worktrack-prod +``` + +--- + +## دیپلوی + +```bash +npm --prefix web run build # پورتال +firebase deploy --only functions,hosting --project worktrack-prod +firebase deploy --only firestore:indexes --project worktrack-prod # وقتی ایندکس عوض شده +``` + +بعد از دیپلوی، به پیام «Deploy complete» اکتفا نکنید. مستقل بررسی کنید: + +```bash +# آیا نسخهٔ جدید پورتال واقعاً بالا رفته؟ +curl -sI https://worktrack-prod.web.app | grep -i last-modified + +# آیا تابع سالم بالا آمده؟ (۴۰۱ درست است، ۵۰۰ یعنی کرش) +curl -s -o /dev/null -w "%{http_code}\n" -X POST \ + https://worktrack-prod.web.app/v1/attendance/face/verify \ + -H "Content-Type: application/json" -d '{}' +``` + +--- + +## ساخت APK + +```bash +./gradlew :app:assembleDebug +``` + +خروجی در `app/build/outputs/apk/debug/` چند فایل است: + +| فایل | برای | +|---|---| +| `app-arm64-v8a-debug.apk` | گوشی‌های امروزی — **این را به کاربران بدهید** | +| `app-armeabi-v7a-debug.apk` | گوشی‌های قدیمی‌تر | +| `app-universal-debug.apk` | وقتی معماری دستگاه نامعلوم است | + +نسخهٔ arm64 حدود ۴۸ مگابایت است، نصف نسخهٔ جهانی. + +برای عرضهٔ واقعی به نسخهٔ release با امضا نیاز دارید که هنوز تنظیم نشده: ساخت +keystore و افزودن `signingConfigs`. با R8 حجم به‌مراتب کمتر می‌شود. + +--- + +## CI + +هر push روی گیت‌هاب سه job اجرا می‌کند: بک‌اند (typecheck، تست واحد، تست یکپارچه با +امولاتور Firestore)، وب (typecheck، تست، بیلد)، و اندروید (کامپایل، تست، lint همهٔ +ماژول‌ها). + +اجرای محلی همان چیزی که CI می‌بیند: + +```bash +npm --prefix backend/functions run test:integration +npm --prefix web test && npm --prefix web run build +./gradlew compileDebugKotlin testDebugUnitTest lintDebug +``` + +اگر CI قرمز شد و محلی سبز است، اول نسخهٔ ابزارها را مقایسه کنید — یک بار همین +اتفاق افتاد چون `firebase-tools` به جاوای ۲۱ نیاز دارد و workflow روی ۱۷ بود. + +--- + +## کارهایی که مهلت دارند + +| مورد | مهلت | وضعیت | +|---|---|---| +| Node.js 20 | ۳۰ اکتبر ۲۰۲۶ | ✅ انجام شد — روی nodejs22 | +| `firebase-functions` v5 | ندارد | باز؛ دو نسخهٔ major عقب، نسخهٔ پایدار جدید که آمد انجام شود | +| امضای release اندروید | ندارد | باز؛ بدون آن عرضهٔ عمومی ممکن نیست | diff --git a/docs/14-hosted-demo.md b/docs/14-hosted-demo.md new file mode 100644 index 0000000..2c7c5c1 --- /dev/null +++ b/docs/14-hosted-demo.md @@ -0,0 +1,114 @@ +# 14 — The hosted demo + +The public "try it" tenant behind the demo page on linumic.com: a manager portal +anyone can sign into, and an Android build they can install. + +## Why it is a separate Firebase project + +The demo publishes its own password. That password must never exist in a project +that also holds a real company's attendance and pay, so the demo gets its own +Firebase project and its own Firestore. `seed.js` enforces this rather than +trusting anyone to remember it: + +- a project id containing `prod`, `production` or `live` is **refused outright**, + and no flag overrides that; +- any real project additionally requires `--yes-write-real-data`. + +Both refusals exit non-zero, so a scripted run stops rather than continuing. + +## One-time setup + +The project already exists: **`worktrack-demo-af`** (the plain `worktrack-demo` +id was taken by someone else — GCP project ids are globally unique). Its web app +and `web/.env.demo` are set up, and `.firebaserc` has a `demo` alias. + +1. **Enable billing.** Cloud Functions cannot deploy on the free plan, so the + demo project needs a billing account. This is the only step that costs money, + and at demo volume it is negligible: + + ``` + gcloud billing projects link worktrack-demo-af --billing-account= + ``` + +2. **Provision Firestore and Auth** in the Firebase console for the project: + create the Firestore database, and enable the Email/Password sign-in provider. + +3. **Build the portal against the demo project.** Move `.env.local` aside first — + Vite lets it override, which would bake emulator config into the build: + + ``` + mv web/.env.local web/.env.local.off + npm --prefix web run build -- --mode demo --outDir dist-demo + mv web/.env.local.off web/.env.local + ``` + +4. **Deploy**, using the demo's own config file so the production bundle in + `web/dist` can never be published to the demo by accident (or the reverse): + + ``` + npx firebase deploy --config firebase.demo.json --project demo + ``` + +5. **Seed the tenant:** + + ``` + node backend/functions/seed.js --target worktrack-demo-af --yes-write-real-data + ``` + + This creates the company (شرکت ساختمانی کابل), its branch and geofence, three + shifts, eight employees across four roles, the last seven days of attendance + including late arrivals and corrections, leave balances and requests, and a + finished payroll run with payslips. + +6. **Publish the APK** so the demo page's download link resolves. Copy the signed + release into the demo bundle before deploying: + + ``` + cp app/build/outputs/apk/release/app-arm64-v8a-release.apk web/dist-demo/worktrack.apk + ``` + + Note this is the *production-signed* APK pointing at production. For a demo + build that talks to the demo backend, rebuild with the endpoint passed in — + see "The Android build" below. + +The demo is then at **https://demo.linumic.com** — a CNAME on the linumic.com +domain pointing at this project's Firebase Hosting site: + +``` +demo CNAME worktrack-demo-af.web.app +``` + +Firebase issues and renews the certificate itself. The original +`worktrack-demo-af.web.app` address keeps working; both serve the same site, and +nothing redirects between them, so an APK built against either endpoint is fine. No custom domain is +needed; `worktrack.af` does not currently resolve at all. + +## Keeping it clean + +The demo is shared and anyone can write to it, so it drifts. Re-running the seed +overwrites the seeded documents in place; it does not delete anything a visitor +added. To reset properly, delete the `companies/comp_kabul` document tree and the +demo auth users, then seed again. + +A daily reset is worth setting up before the page is linked publicly. + +## The Android build + +The demo APK is a **release** build pointing at the demo backend, not the debug +build. Debug defaults to the local emulator (see `app/build.gradle.kts`), so +build the demo APK with the endpoint passed in: + +``` +./gradlew :app:assembleRelease -Pworktrack.apiBaseUrl=https://demo.linumic.com/v1/ +``` + +Release signing is configured: the key is read from `~/.gradle/gradle.properties` +(outside the repo) or `keystore.properties`. With neither present the output is +named `…-release-unsigned.apk`, so an unsigned build cannot be shipped by +mistake. + +## Licensing in the demo + +The demo tenant's licence should stay generous and unenforced (`enforceDevices: +false`), so visitors installing the app never hit a seat limit. Set it from the +portal under **Devices & licence**, or leave it on the default free licence. diff --git a/docs/15-ios-app.md b/docs/15-ios-app.md new file mode 100644 index 0000000..786eab0 --- /dev/null +++ b/docs/15-ios-app.md @@ -0,0 +1,342 @@ +# ۱۵ — اپلیکیشن iOS + +این سند پیش از نوشتن اولین خط کد iOS نوشته شده. هدفش این است که یک روز کار +روی چیزی نرود که در نهایت نمی‌توانیم به دست مشتری برسانیم. + +--- + +## اول از همه: یک واقعیت که باید بدانید + +> **به‌روزرسانی ۱۸ سنبله ۱۴۰۵ (۸ سپتمبر ۲۰۲۶).** این بخش وقتی نوشته شد که هنوز +> حساب Apple Developer نداشتیم و فرض من این بود که ثبت‌نام از افغانستان انجام +> می‌شود یا نمی‌شود. حالا حساب **فعال است** — ثبت‌نام **فردی** و از **کانادا**. +> این یک چیز را عوض می‌کند و یک چیز را نه. هر دو را پایین می‌خوانید. + +**دربارهٔ افغانستان دو فهرست جدا وجود دارد و من در نسخهٔ اول این سند آن دو را +یکی گرفته بودم.** تصحیحش می‌کنم: + +- **سمت کاربر:** صفحهٔ رسمی اپل («در دسترس بودن سرویس‌های Apple Media») افغانستان + را **دارد**، زیر بخش Middle East، با App Store و Apple Arcade — ولی بدون خرید و + اشتراک. یعنی برخلاف آنچه نوشته بودم، Apple ID افغانستانی وجود دارد و App Store + روی گوشی افغان باز می‌شود، دست‌کم برای اپ رایگان. +- **سمت توسعه‌دهنده:** اینکه در App Store Connect بتوانید افغانستان را به‌عنوان + territory انتخاب کنید چیز دیگری است. تاپیک انجمن اپل که در همان نسخهٔ اول هم + به آن ارجاع داده بودم می‌گوید افغانستان و آندورا و آروبا در فهرست territory‌ها + نیستند. آن تاپیک قدیمی است و با صفحهٔ بالا نمی‌خواند. + +**این تناقض را از بیرون نمی‌شد حل کرد. حالا از داخل حل شد.** + +> **جواب: افغانستان هست.** در ۱۴۰۵/۰۶/۲۴ (۲۰۲۶‑۰۹‑۱۵) قیمت و دسترسی اپ را در +> App Store Connect تنظیم کردیم. فهرست ۱۷۵ کشور است و Afghanistan (USD) در آن +> هست — در واقع اولین ردیف بعد از کشور پایه. اپ روی «همهٔ ۱۷۵ کشور» و رایگان +> تنظیم شد. پس **انتشار مستقیم برای مشتری افغان ممکن است** و آن تاپیک قدیمی +> انجمن اپل دیگر معتبر نیست. + +اگر روزی این برگردد، مشتری باید با حساب امارات/ترکیه بگیرد — که خیلی از آیفون‌دارهای افغان از قبل دارند، چون تا همین +اواخر خرید و اشتراک در حساب افغانستان کار نمی‌کرد. **در هر دو حالت TestFlight +کار می‌کند**، چون تست‌فلایت فقط یک Apple ID می‌خواهد و به territory اپ گره +نخورده. برای همین راه امروز ما تست‌فلایت است، مستقل از اینکه جواب آن فهرست چه +باشد. + +آنچه ثبت‌نام **فردی** یعنی: اگر روزی در App Store منتشر کنید، نام فروشنده +«Aminullah Hashemi» است، نه «Linumic». برای TestFlight فرقی نمی‌کند. تبدیل +حساب فردی به سازمانی بعداً ممکن است ولی D‑U‑N‑S می‌خواهد و وقت می‌برد؛ اگر +قرار است اپ زیر نام برند منتشر شود، بهتر است پیش از انتشار تصمیم بگیرید نه بعد. + +این با اندروید فرق بنیادی دارد. امروز مدل تحویل ما این است: مشتری از +`linumic.com/app/` فایل APK را می‌گیرد و نصب می‌کند. **در iOS چنین چیزی وجود +ندارد.** هیچ راه معادلِ «فایل را بده و نصب کند» نیست — و این تنها چیزی است که +با داشتن حساب هم عوض نشد. + +### راه‌های واقعی توزیع iOS + +| راه | نیاز | محدودیت | برای ما؟ | +|---|---|---|---| +| App Store | حساب ✓ (فعال) | فروشگاه افغانستان وجود ندارد؛ ولی کاربر افغان از فروشگاه امارات/ترکیه می‌گیرد | ✓ برای انتشار عمومی | +| TestFlight | حساب ✓ (فعال) | هر build بعد از ۹۰ روز منقضی می‌شود و باید نسخهٔ نو بدهید؛ ۱۰٬۰۰۰ نفر با لینک عمومی | ✓ **راه امروز ما** | +| Ad-hoc | حساب ✓ (فعال) | ۱۰۰ دستگاه در سال، UDID هر گوشی، تمدید سالانه | برای چند مشتری VIP | +| Apple Developer Enterprise | ۲۹۹$ + D‑U‑N‑S | فقط برای کارمندان **خودِ شرکت**؛ توزیع به مشتری نقض قرارداد است و certificate باطل می‌شود | ✗ (به‌هیچ‌وجه) | +| Apple ID رایگان | — | ۷ روز اعتبار، ۳ اپ، هر هفته re-sign | دیگر لازم نیست | + +سطر Enterprise را جدی بگیرید. وسوسه‌کننده است و جواب هم می‌دهد — تا روزی که اپل +certificate را باطل کند و اپ روی گوشی **همهٔ** مشتریان هم‌زمان از کار بیفتد. + +--- + +## پیشنهاد من: قبل از اپ native، پورتال را روی iPhone درست کنید + +پورتال شما همین الان روی Safari آیفون کار می‌کند و هیچ دروازه‌ای ندارد — نه +App Store، نه امضا، نه انتظار برای review. با «Add to Home Screen» آیکون +می‌گیرد و full-screen باز می‌شود. روی ۳۷۵px (اندازهٔ آیفون) تست شد: بدون +سرریز افقی. + +> **این قید برداشته شد.** وقتی این سند نوشته شد `MANAGER_ROLES` نقش `EMPLOYEE` +> را نداشت و کارمند ساده با پیام «این حساب دسترسی مدیریتی ندارد» رد می‌شد. حالا +> `PORTAL_ROLES` در `web/src/auth/AuthProvider.tsx` کارمند را هم راه می‌دهد و +> «/» او را به «کار من» می‌فرستد، نه به داشبورد شرکت که هر درخواستش +> `attendance:read` می‌خواهد. یعنی کارگری که گوشی‌اش اپ را نمی‌برد، از Safari +> کار خودش را می‌بیند — و بس؛ `web/src/auth/PortalAccess.test.tsx` این «و بس» +> را نگه می‌دارد. + +آنچه یک PWA روی iOS **می‌تواند**: + +- برای مدیر: داشبورد، حاضری، کار و پروژه، رخصتی، معاش — همهٔ اینها الان کار می‌کند +- `navigator.geolocation` برای حاضری GPS (وقتی اپ باز است) +- دوربین با `` یا `getUserMedia` برای QR + +آنچه **نمی‌تواند** — و این‌ها دقیقاً همان‌هایی‌اند که اپ اندروید برایشان ساخته شد: + +- **outbox آفلاین** آن‌طور که ما داریم؛ Background Sync روی iOS نیست +- **geofence در پس‌زمینه** — Safari وقتی بسته است موقعیت نمی‌گیرد +- **تشخیص چهره** با مدل TFLite روی دستگاه +- کارکرد قابل‌اتکا وقتی سایت هفته‌ها باز نشده (Safari داده‌های PWA را پاک می‌کند) + +پس PWA جای اپ کارگر ساحه را نمی‌گیرد، **ولی جای اپ مدیر را می‌گیرد** — و مدیر +همان کسی است که معمولاً آیفون دارد. یک تا دو روز کار است، نه چند هفته. + +و برعکسش هم درست است و باید صریح گفته شود: **کارمندی که آیفون دارد امروز هیچ +راهی به ورک‌ترک ندارد.** نه اپ (اندروید است)، نه پورتال (نقش EMPLOYEE را راه +نمی‌دهد). اگر چنین کارمندانی دارید، این تنها شکاف واقعی محصول است — و ارزان‌ترین +راه بستنش باز کردن پورتال به کارمند است، نه ساختن اپ iOS. + +اگر باز هم اپ native می‌خواهید (که تصمیم شماست و دلایل خوبی هم دارد — بازار +بیرون افغانستان، اعتبار محصول)، ادامهٔ سند برنامهٔ ساختش است. + +--- + +## معماری: چه چیزی واقعاً قابل اشتراک است + +اندازه‌گیری شده، نه حدس: + +| بخش | خط کد | وابسته به اندروید؟ | +|---|---:|---| +| `core:common` | ۳۳۵ | **نه** | +| `core:model` | ۴۷۷ | **نه** | +| `core:domain` | ۸۵۵ | **نه** | +| `core:data` | ۱٬۷۸۹ | بله (Retrofit) | +| `core:database` | ۹۹۷ | بله (Room) | +| `core:network` | ۸۸۲ | بله (Retrofit/OkHttp) | +| `core:datastore` + `core:sync` | ۳۳۶ | بله | +| `core:designsystem` + feature‌ها | ۵٬۷۵۸ | بله (Compose) | +| **جمع** | **~۱۱٬۴۰۰** | | + +**۱٬۶۶۷ خط (۱۴٪) هیچ import اندرویدی ندارد** — مدل‌ها، قواعد و use case‌ها. +همان ۱۴٪ ارزشمندترین بخش است (قواعد حاضری، جیوفنس، انتخاب کار امروز)، ولی +۱۴٪ است. + +### گزینه‌ها + +**الف) Kotlin Multiplatform** — همان ۱٬۶۶۷ خط را به `commonMain` ببریم. +دام: آن ماژول‌ها ۲۴ بار `java.time` وارد می‌کنند که در `commonMain` وجود ندارد. +یعنی مهاجرت به `kotlinx-datetime` و تغییر امضای تقریباً هر تابع — و ۱۱٬۴۰۰ خط +اندروید باید با آن هماهنگ شود. برای اشتراک ۱٬۶۰۰ خط، باید ۲٬۷۰۰ خط +(Room→SQLDelight، Retrofit→Ktor) بازنویسی شود. + +**ب) SwiftUI بومی، و قرارداد را API بدانیم.** ✅ **پیشنهاد من** + +`/v1` همین حالا قرارداد واقعی است — پورتال وب با همان صحبت می‌کند و هیچ کد +مشترکی با اپ اندروید ندارد. iOS هم همان کار را می‌کند. دو پیاده‌سازی مستقل از +قواعد داریم که هر دو در برابر همان سرور تست می‌شوند؛ همان چیزی که الان بین +پورتال و اپ برقرار است و مشکلی نساخته. + +**ج) Compose Multiplatform** — UI مشترک. هنوز برای محصولی که به آن پول +می‌گیرید ریسک دارد، مخصوصاً با RTL و فونت فارسی. + +--- + +## قرارداد چهره — خطرناک‌ترین بخش + +اگر یک چیز را از این سند بردارید، این باشد. + +`FaceEmbedder.kt` روی گوشی embedding می‌سازد و **فقط عدد** به سرور می‌رود. +سرور (`face-math.ts`) با cosine similarity مقایسه می‌کند و آستانه **۰٫۶** است. + +یعنی: **کارمندی که چهره‌اش را روی اندروید ثبت کرده، باید روی آیفون هم شناخته +شود.** برای این کار iOS باید *دقیقاً* همان بردار را بسازد: + +| | مقدار | +|---|---| +| مدل | `mobilefacenet.tflite` — همان فایل، در `feature/attendance/src/main/assets/` | +| ورودی | ۱۱۲×۱۱۲ | +| ترتیب کانال | R، G، B — به همین ترتیب | +| نرمال‌سازی | `(channel − 127.5) / 128` | +| خروجی | ۱۹۲ بُعدی، L2-normalized | +| برش صورت | همان کادری که ML Kit می‌دهد | + +TensorFlow Lite روی iOS اجرا می‌شود، پس مدل مشکلی ندارد. **خطر در پیش‌پردازش +است.** اگر برش صورت کمی متفاوت باشد، یا BGR به‌جای RGB، یا نرمال‌سازی +`/255` به‌جای `/128`، کد کامپایل می‌شود، اجرا می‌شود، عدد برمی‌گرداند — و +شباهت زیر ۰٫۶ می‌افتد. نتیجه: **کارمند سر کار است و اپ نمی‌شناسدش.** + +بدترین شکل خرابی هم همین است: خطا نمی‌دهد، فقط رد می‌کند. + +**تست پذیرش:** یک نفر روی اندروید ثبت‌نام کند، همان نفر روی آیفون verify شود و +similarity گزارش‌شدهٔ سرور بالای ۰٫۸ باشد. زیر آن یعنی پیش‌پردازش فرق دارد. +این تست باید قبل از هر کار دیگری روی چهره نوشته شود. + +--- + +## نگاشت فنی + +| اندروید | iOS | +|---|---| +| Room | SQLite با GRDB (یا SwiftData اگر iOS 17+ کافی است) | +| Retrofit + OkHttp | `URLSession` + `Codable` | +| Hilt | init ساده یا swift-dependencies | +| WorkManager | `BGTaskScheduler` — **کمتر قابل‌اتکا؛ iOS تضمین اجرا نمی‌دهد** | +| DataStore | Keychain (توکن) + `UserDefaults` | +| CameraX + ML Kit | `AVFoundation` + `Vision` (`VNDetectFaceRectanglesRequest`) | +| Geofence | Core Location + `CLCircularRegion` | +| ULID outbox | همان الگو، جدول SQLite — منطقش قابل کپی است | +| `deviceId` در DataStore | `identifierForVendor`، ذخیره در Keychain | + +دو نکته که بعداً گاز می‌گیرند: + +1. **`identifierForVendor` با حذف اپ عوض می‌شود.** اگر کارمند اپ را پاک و + دوباره نصب کند، دستگاهش **یک seat جدید** از لایسنس می‌گیرد و شرکت به سقف + می‌خورد. باید در Keychain نگه داشته شود (Keychain از حذف اپ جان سالم + می‌برد)، وگرنه بخش لایسنس ما به مشکل می‌خورد. +2. **پس‌زمینه در iOS تضمینی نیست.** sync ما باید موقع باز شدن اپ اجرا شود، نه + با تکیه بر `BGTaskScheduler`. + +--- + +## فردا: چه کار کنیم (بدون امضا) + +بدون امضا **همه‌چیز روی Simulator کار می‌کند** — کل روز کار مفید داریم. فقط +روی آیفون واقعی نمی‌رود، و آن یعنی GPS واقعی، دوربین واقعی و تست چهره عقب +می‌افتند. + +۱. **پروژهٔ Xcode**، SwiftUI، هدف iOS 16. +۲. **لایهٔ شبکه** روی `/v1` — همان envelope `{data}` و همان problem+json. + با `https://demo.linumic.com` تست کنید؛ زنده است و داده دارد. +۳. **ورود** با Firebase Auth SDK for iOS، توکن در Keychain. +۴. **`GET /v1/work/mine`** — صفحهٔ «کار امروز / روز کاری بعد». + عمداً اولین صفحه: کوچک است، آفلاین ندارد، و کل مسیر + (شبکه → مدل → UI فارسی RTL) را از سر تا ته ثابت می‌کند. +۵. **RTL و تقویم شمسی** را همان‌جا حل کنید، نه بعداً. منطق شمسی در + `core/common/.../SolarHijri.kt` است و ترجمه‌اش به Swift مستقیم است. +۶. بعد: حاضری، سپس آفلاین/outbox، و **چهره در آخر** با آن تست پذیرش. + +ترتیب مهم است. اگر از حاضری شروع کنید، هم‌زمان با GPS، دوربین، آفلاین و RTL +درگیر می‌شوید و هیچ‌کدام تمام نمی‌شود. + +### توزیع، عملاً — دستورالعمل تکرارشونده + +**۱۸ سنبله ۱۴۰۵: انجام شد و کار می‌کند.** اپ از TestFlight روی یک آیفون ۱۵ پرو +مکس نصب شد. آنچه پایین است همان کاری است که واقعاً جواب داد، نه نقشهٔ نظری. + +یک بار انجام شده و دیگر تکرار نمی‌شود: + +| کار | کجا | +|---|---| +| Team ID در `ios/project.yml` | `27RXPRW77S` | +| لاگین Apple ID | Xcode → Settings → Accounts | +| Developer Mode روی گوشی | iPhone → Settings → Privacy & Security | +| ثبت UDID دستگاه | developer.apple.com → Devices | +| رکورد اپ | App Store Connect → «Linumic WorkTrack» | +| کلید API | `~/.appstoreconnect/private_keys/AuthKey_LY3MRPLPAB.p8` | + +**هر بار که build تازه می‌فرستید** (دست‌کم هر ۹۰ روز، وگرنه اپ روی گوشی تسترها +باز نمی‌شود): + +```bash +cd ios +# CURRENT_PROJECT_VERSION را در project.yml یک عدد بالا ببرید — اپل build با +# شمارهٔ تکراری را رد می‌کند، و ردش بعد از تمام‌شدن archive و export و آپلود +# می‌آید. دست به WorkTrack/Info.plist نزنید: آن فایل از روی project.yml ساخته +# می‌شود و هر تغییری در آن با اجرای بعدی xcodegen بی‌صدا پاک می‌شود. +xcodegen generate && pod install +xcodebuild -workspace WorkTrack.xcworkspace -scheme WorkTrack \ + -configuration Release -destination 'generic/platform=iOS' \ + -archivePath /tmp/WorkTrack.xcarchive -allowProvisioningUpdates archive +xcodebuild -exportArchive -archivePath /tmp/WorkTrack.xcarchive \ + -exportPath /tmp/WorkTrackExport \ + -exportOptionsPlist ios/exportOptions.plist -allowProvisioningUpdates +xcrun altool --upload-app -f /tmp/WorkTrackExport/WorkTrack.ipa -t ios \ + --apiKey LY3MRPLPAB --apiIssuer 0e948a64-b5af-4815-bc6c-f7943bb4f637 +``` + +بعد در App Store Connect → TestFlight، build تازه را به گروه Internal Testing +اضافه کنید. برای تست داخلی هیچ بررسی‌ای از طرف اپل لازم نیست. + +**سه چیزی که وقت گرفت و دفعهٔ بعد نباید بگیرد:** + +- **کیبل شارژ ≠ کیبل دیتا.** گوشی شارژ می‌شد و مک اصلاً نمی‌دیدش. تشخیصش با + `ioreg -p IOUSB -w0 | grep iPhone` است: اگر iPhone در فهرست USB نیست، مشکل + کیبل است نه تنظیمات. +- **`xcodebuild` دستگاه را خودش ثبت نمی‌کند** (فقط Xcode گرافیکی). UDID را از + خطای build بردارید و دستی در Devices ثبت کنید. +- **`TARGETED_DEVICE_FAMILY` باید روی TARGET باشد.** XcodeGen خودش `1,2` را روی + هر target می‌نویسد و تنظیم سطح پروژه را بی‌صدا بی‌اثر می‌کند — اولین بار + درست به نظر می‌رسید و خروجی عوض نشده بود. اپل هم اپ portrait-only را که ادعای + iPad دارد رد می‌کند (خطای ۹۰۴۷۴). + +**و یک چیز که هرگز تمام نمی‌شود:** هر build بعد از ۹۰ روز منقضی می‌شود. اپ روی +گوشی تستر باز نمی‌شود و پیام «Beta has expired» می‌دهد. این یعنی **هر سه ماه یک +آپلود اجباری**، حتی اگر هیچ کد تازه‌ای ننوشته باشید. در اندروید چنین چیزی نداریم +و اگر روزی iOS به دست مشتری واقعی برسد، این باید در تقویم کاری باشد نه در حافظهٔ +کسی. + +--- + +## خلاصه در یک بند + +اپ iOS ساخته شد و روی شبیه‌ساز کار می‌کند؛ حساب Apple Developer هم فعال شد و +مانع اصلی این سند برداشته شد. یک نکته را در نسخهٔ اول اشتباه نوشته بودم و اینجا +تصحیح شد: افغانستان در فهرست رسمی اپل App Store **دارد**؛ آنچه روشن نیست این +است که به‌عنوان territory انتخاب‌شدنی هست یا نه، و آن را با حساب خودتان در یک +دقیقه می‌بینید. **راه توزیع امروز، مستقل از آن جواب، TestFlight است.** چیزی که +عوض نشد: معادل «APK را بده» در iOS وجود ندارد — هر نصب از مسیر اپل می‌گذرد، و +هر build تست‌فلایت بعد از ۹۰ روز منقضی می‌شود و باید نسخهٔ تازه بدهید. این یک +هزینهٔ تکرارشوندهٔ سه‌ماهه است که در اندروید نداریم و باید در برنامهٔ کاری دیده شود. + +--- + +**منابع بررسی‌شده:** +[در دسترس بودن سرویس‌های Apple Media (افغانستان را فهرست می‌کند)](https://support.apple.com/en-us/HT204411) · +[تاپیک انجمن: افغانستان در فهرست territory‌ها نیست](https://developer.apple.com/forums/thread/44036) · +[Apple Developer Program enrollment](https://developer.apple.com/programs/enroll/) · +[مدیریت availability اپ](https://developer.apple.com/help/app-store-connect/manage-your-apps-availability/manage-availability-for-your-app-on-the-app-store/) + +--- + +## آنچه به اپل اظهار کردیم (۱۴۰۵/۰۶/۲۴) + +این بخش ثبت می‌کند که در App Store Connect چه جوابی دادیم و **چرا** — چون در هر +به‌روزرسانی دوباره از شما پرسیده می‌شود و جواب باید همان بماند، مگر اینکه اپ +واقعاً عوض شده باشد. + +| میدان | مقدار | +| --- | --- | +| دستهٔ اصلی / فرعی | Business / Productivity | +| قیمت | رایگان، ۱۷۵ کشور (لایسنس شرکت بیرون از App Store فروخته می‌شود) | +| دسترسی | همهٔ ۱۷۵ کشور، «Available on App Release» | +| رده‌بندی سنی | **۴+** | +| Support URL | `https://worktrack-prod.web.app/support/` | +| Marketing URL | `https://linumic.com` | +| Apple Silicon Mac | **خاموش** | +| Apple Vision Pro | **خاموش** | + +**چرا Mac و Vision Pro را خاموش کردیم.** هر دو به‌صورت پیش‌فرض روشن بودند. +تمام ارزش ورک‌ترک این است که حاضری به یک **جای فیزیکی** گره خورده؛ مک فقط +موقعیت تقریبیِ مبتنی بر Wi‑Fi دارد و ژئوفنس را بی‌معنا می‌کند. اپ هم portrait و +فقط‌آیفون است (`TARGETED_DEVICE_FAMILY: "1"`). + +**چرا به «User-Generated Content» جواب «نه» دادیم.** اپ سه میدان متنی دارد: +ایمیلِ ورود، دلیلِ درخواست اصلاح، و دلیلِ رخصتی. دو تای آخر را کارمند می‌نویسد +و مدیرِ **همان شرکت** می‌خواند. تعریف خود اپل این است: + +> «شامل **توزیع گسترده‌ی** محتوای ساخته‌شده توسط کاربران به‌عنوان بخشی از تجربهٔ +> موردنظر اپ.» + +«توزیع گسترده» تعیین‌کننده است: یک دلیلِ رخصتی که یک مدیر می‌خواند توزیع گسترده +نیست. جواب «بله» ما را موظف می‌کرد سازوکار گزارش/مسدودسازی/فیلتر محتوا داشته +باشیم (بند ۱.۲) — که برای یک میدانِ «دلیل رخصتی» بی‌معناست. به همین ترتیب +Messaging and Chat هم «نه» است: کارمندان با هم حرف نمی‌زنند، درخواست به مدیر +می‌رود، و این یک فرم است نه چت. + +**یک سؤال که جواب طبیعی ندارد:** «Social Media Disabled for Users Under 13». +اجباری است ولی فرض می‌کند اپ شبکهٔ اجتماعی دارد. «بله» یعنی ما Declared Age +Range API را صدا می‌زنیم، که نمی‌زنیم. پس «نه» دادیم: ادعای نادرستی نمی‌کند و +چون Social Media از قبل «نه» است، روی رده‌بندی اثری ندارد. diff --git a/docs/16-business-types-and-gaps.md b/docs/16-business-types-and-gaps.md new file mode 100644 index 0000000..26e9c90 --- /dev/null +++ b/docs/16-business-types-and-gaps.md @@ -0,0 +1,207 @@ +# ۱۶ — دسته‌بندی کسب‌وکارها، و آنچه هنوز نداریم + +این سند جواب یک سؤال است: «در پورتال و اپ چه چیزی باید اضافه شود؟» + +عمداً فهرست قابلیت‌های عمومی HR نیست — آن را هر کسی می‌تواند از اینترنت کپی +کند و به درد مشتری افغان نمی‌خورد. اینجا سه چیز است: آنچه امروز **واقعاً** +داریم، شکاف‌هایی که در بازار افغانستان **گران** تمام می‌شوند، و اینکه ایدهٔ +دسته‌بندی شرکت چطور باید پیاده شود تا محصول را نشکند. + +--- + +## اول: امروز کجاییم + +قابلیت‌هایی که در `services/settings.ts` قابل روشن و خاموش کردن‌اند: + +``` +shifts · leave · payroll · regularization · announcements +geofencing · qrKiosk · faceRecognition · finance +``` + +و سیاست‌ها فقط چهار عدد: `standardDailyMinutes`, `weekendDays`, +`lateGraceMinutes`, `overtimeEnabled`. + +دامنه‌های API: حاضری، رخصتی، معاش، فیش، شیفت، کار و پروژه، مالی، کیوسک، +دستگاه‌ها، اطلاعیه، تقویم، پشتیبانی، CRM فروشنده. + +**این پایهٔ محکمی است.** چیزی که کم است، عمق در جاهایی است که پول جابه‌جا +می‌شود و اعتماد ساخته می‌شود. + +--- + +## بخش یک: سه چیزی که همین حالا شکسته‌اند + +اینها منتظر تصمیم محصولی نیستند، اشکال‌اند: + +**۱. ویرایش کارمند به Firebase Auth دست نمی‌زند.** +`PUT /employees/:id` فقط Firestore را می‌نویسد. اگر ایمیل کارمندی را عوض کنید، +رکوردش عوض می‌شود ولی **ورودش نه** — همچنان با ایمیل قدیمی وارد می‌شود و هیچ +پیامی این را نمی‌گوید. برای پشتیبانی کابوس است: «ایمیلش را عوض کردم ولی وارد +نمی‌شود.» + +**۲. نقش کارمند هیچ‌وقت قابل تغییر نیست.** +نقش فقط هنگام ساختن تعیین می‌شود. ترفیع، تنزل، یا اشتباه ساده — هیچ راهی نیست. +(امشب خودمان به آن خوردیم: حسابی با نقش «بازرس» ساخته شد و قابل اصلاح نبود.) + +**۳. حذف یا بایگانی کارمند وجود ندارد.** +تنها `DELETE` روی `/:id/face` است. کارمندی که اشتباه ثبت شده تا ابد در فهرست +می‌ماند. (نبودِ حذفِ سابقهٔ استخدام درست است — ولی «بایگانی» باید باشد.) + +> **خواستهٔ صریح:** کارمند باید از هر لحاظ قابل ویرایش باشد. این یعنی هر سهٔ +> بالا، و مهم‌تر از همه اینکه ویرایش ایمیل و نقش باید **claim‌های Auth را هم** +> به‌روز کند، وگرنه فقط ظاهر عوض می‌شود. + +--- + +## بخش دو: شکاف‌هایی که در افغانستان گران‌اند + +به ترتیب اهمیت — این ترتیب نظر من است و جای بحث دارد. + +### ۱. پیش‌پرداخت و قرض کارمند ⭐ مهم‌ترین + +در کسب‌وکار افغان، کارگر وسط ماه پول می‌گیرد. این استثنا نیست، **قاعده** است. +امروز ما هیچ جایی برای ثبتش نداریم، پس حسابدار در دفترچه می‌نویسد و موقع معاش +دستی کم می‌کند — یعنی همان کاری که قرار بود ورک‌ترک از بین ببرد. + +لازم است: ثبت پیش‌پرداخت با تاریخ، کسر خودکار از معاش ماه، قسط‌بندی برای مبالغ +بزرگ، و مانده‌ای که هم مدیر و هم خود کارگر در اپ ببیند. + +**بدون این، سیستم معاش ما در عمل نیمه‌کاره است.** + +### ۲. معاش روزمزد و کارمزدی + +امروز معاش ماهانه است. ولی: +- **شرکت ساختمانی** کارگر روزمزد دارد — ۲۰ روز کار، ۲۰ روز مزد. +- **کارگاه خیاطی** کارمزدی می‌دهد — به‌ازای هر دست لباس، نه به‌ازای ساعت. + +این دو مدل، بخش بزرگی از بازار هدف‌اند و امروز اصلاً پوشش داده نمی‌شوند. +حاضری برای روزمزد از قبل داریم؛ آنچه نیست، وصل‌کردنش به مبلغ است. + +### ۳. پرداخت نقدی و برگهٔ امضا + +بیشتر کارگران حساب بانکی ندارند. معاش نقدی داده می‌شود و در برابرش امضا یا +**اثر انگشت** گرفته می‌شود. یک شرکت افغان بدون این برگه نمی‌تواند به بازرس مالی +یا به کارفرمای بالادست جواب بدهد. + +لازم است: برگهٔ چاپی معاش با ستون امضا/اثر انگشت، به دری و پشتو، با اعداد +فارسی. و ثبت اینکه چه کسی، چه روزی، نقدی پرداخت کرد. + +> **این یک نکتهٔ عمومی است که همه‌جای محصول صدق می‌کند: دفتر افغان روی کاغذِ +> چاپ‌شده و مهرشده کار می‌کند.** هر گزارشی که قابل چاپ نباشد، در عمل استفاده +> نمی‌شود. امروز هیچ خروجی چاپی درستی نداریم. + +### ۴. اعلان (Notification) + +هنوز در هیچ پلتفرمی وجود ندارد — نه سرور، نه اندروید، نه iOS. یعنی: +- کارگر نمی‌داند رخصتی‌اش تأیید شد +- مدیر نمی‌داند درخواستی منتظر اوست +- کسی نمی‌داند فیش معاشش آماده است + +هر بار باید خودش برنامه را باز کند و بگردد. این تنها شکافی است که **هر سه** +پلتفرم را هم‌زمان لمس می‌کند و بزرگ‌ترین کار مهندسی این فهرست است. + +### ۵. اسناد کارمند و تاریخ انقضا + +تذکره، قرارداد، جواز کار، گواهی صحی. کسب‌وکارهایی که با NGO یا دولت قرارداد +دارند موظف‌اند اینها را نگه دارند. و مهم‌تر: **هشدار انقضا** — قراردادی که +منقضی شده و کسی نفهمیده، یعنی کارمندی که غیرقانونی کار می‌کند. + +### ۶. خروجی اکسل + +حسابدار افغان با اکسل کار می‌کند و خواهد کرد. هر گزارشی که فقط روی صفحه باشد، +دستی دوباره تایپ می‌شود. این کار کوچکی است با ارزش نامتناسب بزرگ. + +--- + +## بخش سه: دسته‌بندی شرکت — چطور، بدون اینکه محصول را بشکند + +### مفهومش نصفه وجود دارد، در جای اشتباه + +`services/crm.ts` یک فیلد `industry` دارد — ولی **متن آزاد**، در یادداشت‌های +فروشنده، و هیچ کاری نمی‌کند. باید از آنجا به خود محصول بیاید و کار کند. + +### هشدار مهم: «پیش‌تنظیم» بسازید، نه «نسخهٔ متفاوت» + +اگر ۱۵ دسته بسازیم و هر کدام رفتار متفاوتی داشته باشد، ۱۵ محصول ساخته‌ایم که +هیچ‌کس نمی‌تواند همه‌شان را تست کند. ۱۵ دسته × ۹ کلید قابلیت = ماتریسی که هر +اشکال در آن فقط برای یک دسته ظاهر می‌شود و پشتیبانی را زمین می‌زند. + +**پیشنهاد من:** دسته فقط **مقادیر پیش‌فرض** را در لحظهٔ ثبت‌نام تعیین کند و +بعد از آن فراموش شود. هر تنظیم مثل امروز جداگانه قابل تغییر بماند. یک محصول +می‌ماند، نه پانزده‌تا. و دسته باید بعداً هم قابل تغییر باشد — کسب‌وکار عوض +می‌شود. + +### دسته‌ها، و اینکه هر کدام واقعاً چه فرقی دارند + +| دسته | فرقِ واقعی | +|---|---| +| **شرکت ساختمانی** | روزمزد، چند ساحه، جابه‌جایی روزانهٔ کارگر بین پروژه‌ها، هزینه به تفکیک پروژه | +| **کارگاه خیاطی** | کارمزدی (به‌ازای قطعه)، اوج فصلی، اغلب کارگر زن | +| **فروشگاه** | شیفت صبح/عصر، تحویل صندوق بین شیفت‌ها، یک محل، پرسنل کم | +| **انبار / لوژستیک** | شیفت، ورود و خروج دروازه، راننده و قراردادی | +| **شرکت امنیتی** | شیفت ۱۲ ساعته، پُست‌های ثابت، QR در هر پُست، روستر همه‌چیز است | +| **رستوران / کافه** | شیفت شکسته، تقسیم انعام | +| **کلینیک / دواخانه** | شیفت شب، اعتبارنامهٔ صحی و انقضایش | +| **مکتب / کورس** | زنگ درسی به‌جای ساعت کاری، معلم بدیل | +| **NGO / پروژه‌ای** | تایم‌شیت به تفکیک دونر — الزام گزارش‌دهی، نه راحتی | +| **صرافی** | پرسنل کم، اعتماد بالا، کنترل دوگانه | +| **شرکت ترانسپورتی** | راننده، سفر، مزد به‌ازای سفر | +| **نانوایی / تولیدی** | شیفت تولید، شمارش تعداد | +| **زراعت** | کارگر فصلی، استخدام روزانه | +| **هوتل / مهمان‌خانه** | شیفت، خانه‌داری | +| **دفتر اداری** | همان چیزی که امروز پیش‌فرض است | + +### آنچه دسته باید تعیین کند + +۱. **کدام قابلیت‌ها روشن باشند** — دفتر اداری نیازی به geofence ندارد؛ شرکت + ساختمانی بدون آن بی‌معنی است. +۲. **مدل معاش پیش‌فرض** — ماهانه، روزمزد، یا کارمزدی. +۳. **سیاست‌ها** — شرکت امنیتی `standardDailyMinutes` دوازده ساعت دارد، نه هشت. +۴. **واژگان** — «ساحه» برای ساختمان، «شعبه» برای فروشگاه، «پُست» برای امنیتی. + همان صفحه، کلمهٔ درست. + +### و یک چیز که کمتر آشکار است ولی مهم‌تر از بقیه است + +**دسته باید پیش‌فرضِ حریم خصوصی را هم تعیین کند.** + +در کارگاه خیاطی که کارگرانش زن‌اند، «عکس هنگام ورود» ممکن است **فرهنگاً +غیرقابل‌قبول** باشد — نه یک قابلیت اضافی، بلکه دلیل نخریدن محصول. تشخیص چهره هم +همین‌طور. + +امروز `faceRecognition` پیش‌فرض خاموش است که درست است. ولی این باید **آگاهانه** +باشد، نه تصادفی: در دسته‌هایی که کارگر زن دارند، خاموش بماند و دلیلش هم نوشته +شود. اینکه محصول بفهمد کجا نباید عکس بگیرد، همان چیزی است که اعتماد می‌سازد. + +--- + +## وضعیت — شب ۱۸ سنبله + +هر سهٔ اولویت پایین همان شب ساخته شدند: + +| کار | وضعیت | +|---|---| +| ویرایش کامل کارمند (نقش، ایمیل، شعبه، بستن دسترسی) | ✅ ساخته و **زنده** | +| دسته‌بندی کسب‌وکار به‌شکل پیش‌تنظیم | ✅ ساخته و **زنده** | +| پیش‌پرداخت و کسر آن از معاش | ✅ ساخته، تست‌شده، **زنده نیست** | + +پیش‌پرداخت عمداً دیپلوی نشد. اولین چیزی است که مبلغ پرداختی به یک آدم واقعی +را عوض می‌کند، و باید وقتی زنده شود که کسی بیدار باشد و اولین اجرای معاش را +ببیند. + +سه شکاف «شکسته» بالا هم بسته شدند — و بزرگ‌ترینشان چیزی بود که در فهرست اولیه +نبود: کارمندی که EXITED یا SUSPENDED می‌شد، **تمام دسترسی‌اش را نگه می‌داشت**. + +--- + +## اگر فقط سه چیز انتخاب کنیم + +به ترتیب: + +۱. **ویرایش کامل کارمند** (شامل نقش و همگام‌سازی با Auth) — چون اشکال است، نه + قابلیت، و هر روز به کسی برمی‌خورد. +۲. **پیش‌پرداخت و قرض** — چون بدون آن، معاش ما در عمل با دفترچه کار می‌کند. +۳. **دسته‌بندی به‌شکل پیش‌تنظیم** — چون ثبت‌نام را از «یک فرم خالی» به «سیستمی + که کار مرا می‌فهمد» تبدیل می‌کند، و ارزان‌ترینِ این سه است. + +اعلان‌ها بزرگ‌ترین کارند و بیشترین اثر را دارند، ولی هر سه پلتفرم را لمس +می‌کنند — بهتر است بعد از این سه، و به‌عنوان یک کار مستقل. diff --git a/docs/17-google-play.md b/docs/17-google-play.md new file mode 100644 index 0000000..a3dd469 --- /dev/null +++ b/docs/17-google-play.md @@ -0,0 +1,124 @@ +# بردن اپ اندروید به Google Play + +تا امروز اندروید را **سایدلود** می‌فروشیم: مشتری APK را از صفحهٔ دانلود +پورتال می‌گیرد. این سند برای رفتن روی Play است، و طوری نوشته شده که فردا +فقط فرم پر کنید نه اینکه متن بنویسید. + +> **وضعیت ۱۴۰۵/۰۶/۲۵:** مانع فنی برداشته شد (targetSdk 36). آنچه مانده به +> حساب Play Console شما گره خورده و بدون شما پیش نمی‌رود. + +--- + +## ۱. آنچه انجام شد + +| | | +| --- | --- | +| targetSdk / compileSdk | ۳۵ → **۳۶** (اجبار Play از ۳۱ اگست ۲۰۲۶) | +| AGP / Gradle | 8.5.2 → **8.13.0** / 8.9 → **8.13** | +| بیلد، لینت، ۵۰ تست | سبز | +| edge-to-edge اندروید ۱۶ | از قبل درست بود، تغییری لازم نشد | + +## ۲. آنچه فقط از دست شما برمی‌آید + +1. **حساب Google Play Developer** — ۲۵ دالر، یک‌بار. حساب فردی مثل اپل، + نام فروشنده «Aminullah Hashemi» می‌شود مگر حساب سازمانی بگیرید. +2. **امضای release** — کلید شماست. من نمی‌سازم و منتشر نمی‌کنم. +3. **Play App Signing** — Google کلید اصلی را نگه می‌دارد. اگر قبولش کنید، + دیگر `worktrack-release.jks` کلیدِ نهایی نیست بلکه upload key می‌شود. + **این تصمیم برگشت‌ناپذیر است.** + +> ⚠️ **هشدار مهم دربارهٔ نصب‌های موجود:** APK امضاشده با کلید فعلی و +> نسخهٔ Play دو امضای متفاوت دارند. کسی که امروز سایدلود کرده، نسخهٔ Play +> را **روی آن آپدیت نمی‌تواند** — باید حذف و از نو نصب کند، و دادهٔ محلیِ +> نفرستاده از بین می‌رود. قبل از کوچ، مشتری‌های فعلی را خبر کنید. + +## ۳. Data Safety — همان چیزی که به اپل گفتیم + +Play این را جدا می‌پرسد ولی جواب‌ها باید با برچسب‌های App Store یکی باشد، +وگرنه دو روایت متناقض از یک محصول بیرون می‌دهید. + +| داده | جمع می‌شود؟ | چرا | به کاربر وصل است؟ | برای ردیابی؟ | +| --- | --- | --- | --- | --- | +| نام | بله | عملکرد اپ | بله | **نه** | +| ایمیل | بله | عملکرد اپ | بله | **نه** | +| شمارهٔ تلفن | بله | عملکرد اپ | بله | **نه** | +| موقعیت **دقیق** | بله | عملکرد اپ (ژئوفنس حاضری) | بله | **نه** | +| اطلاعات مالی (معاش) | بله | عملکرد اپ | بله | **نه** | +| **داده بیومتریک (چهره)** | بله | عملکرد اپ | بله | **نه** | +| شناسهٔ کاربر | بله | عملکرد اپ | بله | **نه** | + +سه جواب دیگر که Play می‌خواهد: + +- **رمزگذاری در انتقال:** بله (HTTPS، بدون استثنا). +- **حذف داده:** بله — بستن حساب در پورتال. آدرسش را همان + `https://worktrack-prod.web.app/support/` بدهید. +- **سیاست حریم خصوصی:** `https://worktrack-prod.web.app/privacy/` + +**نکتهٔ چهره:** Play «داده بیومتریک» را جدی می‌گیرد. در توضیح بنویسید که +عکس ذخیره نمی‌شود و فقط یک بردار عددی روی دستگاه ساخته می‌شود — همان چیزی +که در متن اجازهٔ دوربین هم نوشته‌ایم. این را سرسری نگیرید؛ ناهم‌خوانی اینجا +دلیل رایج رد شدن است. + +## ۴. رده‌بندی محتوا + +پرسشنامهٔ Play (IARC) با اپل فرق دارد ولی جواب‌ها همان است: اپ ابزار کاری +است، همه‌چیز «هیچ/نه». دستهٔ درست **Business** است، نه Productivity. +انتظار رده‌بندی: همه‌سنین / PEGI 3. + +جایی که Play سخت‌گیرتر از اپل است: **«آیا کاربران با هم ارتباط می‌گیرند؟»** +جواب **نه** — دلیل رخصتی و درخواست اصلاح فرم است که مدیرِ همان شرکت +می‌خواند، نه چت. همان استدلالی که به اپل دادیم. + +## ۵. متن صفحهٔ فروشگاه (انگلیسی) + +**App name (۳۰):** +`Linumic WorkTrack` + +**Short description (۸۰):** +`Attendance, leave and payslips for your team — built for Afghan workplaces.` + +**Full description (زیر ۴۰۰۰):** + +``` +WorkTrack is the employee app for companies that run their workforce on +WorkTrack. Your employer creates your account and gives you the sign-in +details — there is no self-registration. + +WHAT YOU CAN DO +• Check in and out of work, and see your hours as they add up +• Check in at the work site, with GPS confirming you are there +• Request leave and follow what happened to it +• Ask for a correction when a check-in did not go through +• Read your payslip in full — earnings, deductions, tax +• See company announcements and the work assigned to you + +BUILT FOR WHERE IT IS USED +• Dari, Pashto and English, right-to-left throughout +• The Afghan calendar, not a converted Gregorian one +• Amounts in AFN +• Works without signal: check-ins are saved on the phone and sent when + the network returns. The time recorded is the time you pressed the + button, so a weak connection never costs you part of a day. + +FACE CHECK-IN (only if your employer turns it on) +Your photo is never saved or uploaded. The phone turns it into a numeric +code and compares that. It is off unless your company asks for it. + +WorkTrack is sold to employers. If you do not have an account, ask your +manager. +``` + +**Graphics هنوز لازم است:** +- آیکون ۵۱۲×۵۱۲ (داریم — از `res/mipmap`) +- Feature graphic ۱۰۲۴×۵۰۰ — **نداریم، باید ساخته شود** +- حداقل ۲ اسکرین‌شات تلفن — از اسکرین‌شات‌های App Store بردارید + +## ۶. ترتیبی که بروید + +1. حساب Play Console بگیرید و ۲۵ دالر را بدهید +2. تصمیم Play App Signing (بخش ۲ را بخوانید، برگشت ندارد) +3. AAB امضاشده بسازید +4. **روی یک گوشی واقعی با اندروید ۱۶ تستش کنید** — targetSdk عوض شده +5. Data Safety و رده‌بندی را از بخش‌های ۳ و ۴ پر کنید +6. متن و گرافیک از بخش ۵ +7. اول **internal testing**، بعد production diff --git a/feature/attendance/build.gradle.kts b/feature/attendance/build.gradle.kts new file mode 100644 index 0000000..7c12bf6 --- /dev/null +++ b/feature/attendance/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + alias(libs.plugins.worktrack.android.feature) +} + +android { + namespace = "app.worktrack.feature.attendance" +} + +dependencies { + implementation(libs.play.services.location) + implementation(libs.kotlinx.coroutines.play.services) + + implementation(libs.camerax.core) + implementation(libs.camerax.camera2) + implementation(libs.camerax.lifecycle) + implementation(libs.camerax.view) + implementation(libs.mlkit.barcode.scanning) + implementation(libs.mlkit.face.detection) + implementation(libs.tflite) + implementation(libs.tflite.support) +} diff --git a/feature/attendance/src/main/AndroidManifest.xml b/feature/attendance/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ca20a0a --- /dev/null +++ b/feature/attendance/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/feature/attendance/src/main/assets/mobilefacenet.tflite b/feature/attendance/src/main/assets/mobilefacenet.tflite new file mode 100644 index 0000000..057b985 Binary files /dev/null and b/feature/attendance/src/main/assets/mobilefacenet.tflite differ diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/di/AttendanceFeatureModule.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/di/AttendanceFeatureModule.kt new file mode 100644 index 0000000..14c7ee3 --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/di/AttendanceFeatureModule.kt @@ -0,0 +1,20 @@ +package app.worktrack.feature.attendance.di + +import android.content.Context +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationServices +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object AttendanceFeatureModule { + + @Provides + fun provideFusedLocationClient( + @ApplicationContext context: Context, + ): FusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context) +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/face/FaceCaptureResult.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/face/FaceCaptureResult.kt new file mode 100644 index 0000000..f207c4a --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/face/FaceCaptureResult.kt @@ -0,0 +1,24 @@ +package app.worktrack.feature.attendance.face + +/** + * Outcome of submitting a captured face embedding. The capture screen shows a + * different message for each case: "we could not reach the server" and "that is + * not your face" are very different things to tell someone standing at a gate. + */ +sealed interface FaceCaptureResult { + + /** Accepted. [token] carries the server's signed proof for verifications. */ + data class Success(val token: String? = null) : FaceCaptureResult + + /** The capture did not match the enrolled face (verification only). */ + data object NoMatch : FaceCaptureResult + + /** Nothing to compare against yet — the employee must enroll first. */ + data object NotEnrolled : FaceCaptureResult + + /** Already enrolled; an administrator must reset it before re-enrolling. */ + data object AlreadyEnrolled : FaceCaptureResult + + /** Network or server failure — retrying may well succeed. */ + data object Failed : FaceCaptureResult +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/face/FaceEmbedder.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/face/FaceEmbedder.kt new file mode 100644 index 0000000..9dd7d3a --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/face/FaceEmbedder.kt @@ -0,0 +1,112 @@ +package app.worktrack.feature.attendance.face + +import android.content.Context +import android.graphics.Bitmap +import org.tensorflow.lite.Interpreter +import java.io.FileInputStream +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.channels.FileChannel +import kotlin.math.sqrt + +/** + * On-device face embedding with a MobileFaceNet TFLite model. + * + * Identity matching is done by comparing embeddings (cosine similarity on the + * server). Only the resulting numeric vector ever leaves the device — never the + * photo — so no biometric image is uploaded or stored. + * + * SETUP: drop a MobileFaceNet model (112×112 input → 192-d output) at + * `feature/attendance/src/main/assets/mobilefacenet.tflite`. A widely used + * open model works; if its output size differs (e.g. 128), update + * [EMBEDDING_SIZE]. + */ +/** The TFLite model asset is missing or unreadable on this device. */ +class FaceModelUnavailableException(cause: Throwable) : Exception(cause) + +class FaceEmbedder(context: Context) : AutoCloseable { + + private val appContext = context.applicationContext + private val interpreterDelegate = lazy { Interpreter(loadModel()) } + private val interpreter: Interpreter by interpreterDelegate + + /** + * Guards the native interpreter: [close] must not free it while [embed] is + * still running on a background dispatcher (backing out of the screen + * mid-capture would otherwise crash in native code). + */ + private val lock = Any() + private var closed = false + + /** Embedding size read from the model itself, so 128-d or 192-d both work. */ + val embeddingSize: Int by lazy { interpreter.getOutputTensor(0).shape().last() } + + /** A cropped face bitmap → L2-normalized embedding, ready to send to the API. */ + fun embed(face: Bitmap): FloatArray = synchronized(lock) { + check(!closed) { "FaceEmbedder was closed" } + val output = Array(1) { FloatArray(embeddingSize) } + interpreter.run(preprocess(face), output) + l2Normalize(output[0]) + } + + private fun preprocess(bitmap: Bitmap): ByteBuffer { + val resized = Bitmap.createScaledBitmap(bitmap, INPUT_SIZE, INPUT_SIZE, true) + val buffer = ByteBuffer + .allocateDirect(INPUT_SIZE * INPUT_SIZE * CHANNELS * Float.SIZE_BYTES) + .order(ByteOrder.nativeOrder()) + val pixels = IntArray(INPUT_SIZE * INPUT_SIZE) + resized.getPixels(pixels, 0, INPUT_SIZE, 0, 0, INPUT_SIZE, INPUT_SIZE) + for (pixel in pixels) { + val r = (pixel shr 16 and 0xFF) + val g = (pixel shr 8 and 0xFF) + val b = (pixel and 0xFF) + // MobileFaceNet normalization: (channel − 127.5) / 128 + buffer.putFloat((r - 127.5f) / 128f) + buffer.putFloat((g - 127.5f) / 128f) + buffer.putFloat((b - 127.5f) / 128f) + } + buffer.rewind() + return buffer + } + + private fun loadModel(): ByteBuffer { + try { + appContext.assets.openFd(MODEL_ASSET).use { fd -> + FileInputStream(fd.fileDescriptor).use { input -> + return input.channel.map( + FileChannel.MapMode.READ_ONLY, + fd.startOffset, + fd.declaredLength, + ) + } + } + } catch (e: IOException) { + // Surfaced distinctly so the UI can say the model is missing rather + // than blaming the capture. + throw FaceModelUnavailableException(e) + } + } + + override fun close() = synchronized(lock) { + if (!closed) { + closed = true + // Only touch the interpreter if something actually built it. + if (interpreterDelegate.isInitialized()) interpreter.close() + } + } + + companion object { + private const val MODEL_ASSET = "mobilefacenet.tflite" + private const val INPUT_SIZE = 112 + private const val CHANNELS = 3 + + /** L2-normalizes so cosine similarity == dot product on the server. */ + fun l2Normalize(v: FloatArray): FloatArray { + var norm = 0f + for (x in v) norm += x * x + norm = sqrt(norm) + return if (norm == 0f) v else FloatArray(v.size) { v[it] / norm } + } + } +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/face/FaceEnrollScreen.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/face/FaceEnrollScreen.kt new file mode 100644 index 0000000..b02268a --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/face/FaceEnrollScreen.kt @@ -0,0 +1,411 @@ +package app.worktrack.feature.attendance.face + +import android.Manifest +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Matrix +import android.graphics.Rect +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.NoPhotography +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import app.worktrack.core.designsystem.component.EmptyState +import app.worktrack.core.designsystem.component.WtPrimaryButton +import app.worktrack.core.designsystem.component.WtTopBar +import app.worktrack.feature.attendance.R +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.face.FaceDetection +import com.google.mlkit.vision.face.FaceDetectorOptions +import java.util.concurrent.Executors +import kotlin.coroutines.resume +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext + +/** + * Face enrollment: guides the employee to position their face, captures one + * frame, computes an on-device embedding ([FaceEmbedder]) and submits ONLY the + * numeric vector via [onSubmit] (never a photo). Verified check-ins then match + * against this embedding on the server. + */ +/** Face enrollment (registers the caller's embedding). */ +@Composable +fun FaceEnrollRoute( + onBack: () -> Unit, + onSubmit: suspend (List) -> FaceCaptureResult, + onDone: (FaceCaptureResult.Success) -> Unit, +) = FaceCaptureRoute( + titleRes = R.string.att_face_enroll_title, + actionRes = R.string.att_face_enroll_capture, + doneRes = R.string.att_face_enroll_done, + onBack = onBack, + onSubmit = onSubmit, + onDone = onDone, +) + +/** Face verification for a check-in (matches against the enrolled embedding). */ +@Composable +fun FaceVerifyRoute( + onBack: () -> Unit, + onSubmit: suspend (List) -> FaceCaptureResult, + onDone: (FaceCaptureResult.Success) -> Unit, +) = FaceCaptureRoute( + titleRes = R.string.att_face_verify_title, + actionRes = R.string.att_face_verify_capture, + doneRes = R.string.att_face_verify_done, + onBack = onBack, + onSubmit = onSubmit, + onDone = onDone, +) + +@Composable +private fun FaceCaptureRoute( + titleRes: Int, + actionRes: Int, + doneRes: Int, + onBack: () -> Unit, + onSubmit: suspend (List) -> FaceCaptureResult, + onDone: (FaceCaptureResult.Success) -> Unit, +) { + val context = LocalContext.current + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED, + ) + } + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> hasCameraPermission = granted } + + LaunchedEffect(Unit) { + if (!hasCameraPermission) permissionLauncher.launch(Manifest.permission.CAMERA) + } + + Scaffold( + topBar = { WtTopBar(title = stringResource(titleRes), onBack = onBack) }, + ) { padding -> + if (hasCameraPermission) { + FaceCaptureContent( + actionRes = actionRes, + doneRes = doneRes, + onSubmit = onSubmit, + onDone = onDone, + modifier = Modifier.fillMaxSize().padding(padding), + ) + } else { + EmptyState( + icon = Icons.Filled.NoPhotography, + title = stringResource(R.string.att_qr_camera_permission_title), + message = stringResource(R.string.att_qr_camera_permission_msg), + modifier = Modifier.padding(padding), + ) + } + } +} + +private sealed interface CapturePhase { + data object Scanning : CapturePhase + data object Submitting : CapturePhase + data class Done(val result: FaceCaptureResult.Success) : CapturePhase + data class Failed(@StringRes val messageRes: Int) : CapturePhase +} + +/** Maps a submission outcome to the message the person at the camera sees. */ +@StringRes +private fun failureMessageOf(result: FaceCaptureResult): Int = when (result) { + FaceCaptureResult.NoMatch -> R.string.att_face_verify_no_match + FaceCaptureResult.NotEnrolled -> R.string.att_face_verify_not_enrolled + FaceCaptureResult.AlreadyEnrolled -> R.string.att_face_enroll_already + else -> R.string.att_face_enroll_error +} + +@Composable +private fun FaceCaptureContent( + actionRes: Int, + doneRes: Int, + onSubmit: suspend (List) -> FaceCaptureResult, + onDone: (FaceCaptureResult.Success) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val scope = rememberCoroutineScope() + val analysisExecutor = remember { Executors.newSingleThreadExecutor() } + val embedder = remember { FaceEmbedder(context) } + val detector = remember { + FaceDetection.getClient( + FaceDetectorOptions.Builder() + .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST) + .build(), + ) + } + val imageCapture = remember { ImageCapture.Builder().build() } + var faceReady by remember { mutableStateOf(false) } + var phase by remember { mutableStateOf(CapturePhase.Scanning) } + + DisposableEffect(Unit) { + onDispose { + detector.close() + embedder.close() + analysisExecutor.shutdown() + ProcessCameraProvider.getInstance(context).get().unbindAll() + } + } + + LaunchedEffect(phase) { + val done = phase as? CapturePhase.Done ?: return@LaunchedEffect + kotlinx.coroutines.delay(1200) + onDone(done.result) + } + + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Box( + Modifier.fillMaxWidth().weight(1f), + contentAlignment = Alignment.Center, + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { viewContext -> + val previewView = PreviewView(viewContext) + val providerFuture = ProcessCameraProvider.getInstance(viewContext) + providerFuture.addListener( + { + val provider = providerFuture.get() + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + analysis.setAnalyzer(analysisExecutor) { proxy -> + detectFace(proxy, detector) { present -> faceReady = present } + } + provider.unbindAll() + provider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_FRONT_CAMERA, + preview, + analysis, + imageCapture, + ) + }, + ContextCompat.getMainExecutor(viewContext), + ) + previewView + }, + ) + // Circular face guide: turns to the brand color once a face is found. + Box( + Modifier + .fillMaxWidth(0.72f) + .aspectRatio(1f) + .clip(CircleShape) + .border( + BorderStroke( + 3.dp, + if (faceReady) MaterialTheme.colorScheme.primary else Color.White.copy(alpha = 0.7f), + ), + CircleShape, + ), + ) + if (phase is CapturePhase.Done) { + Box( + Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)), + contentAlignment = Alignment.Center, + ) { + androidx.compose.material3.Icon( + Icons.Filled.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth(0.25f).aspectRatio(1f), + ) + } + } + } + + Column( + Modifier.fillMaxWidth().padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource( + when (val current = phase) { + CapturePhase.Submitting -> R.string.att_face_enroll_saving + is CapturePhase.Done -> doneRes + is CapturePhase.Failed -> current.messageRes + CapturePhase.Scanning -> + if (faceReady) R.string.att_face_enroll_ready else R.string.att_face_enroll_hint + }, + ), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = when (phase) { + is CapturePhase.Failed -> MaterialTheme.colorScheme.error + is CapturePhase.Done -> MaterialTheme.colorScheme.primary + else -> if (faceReady) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + if (phase == CapturePhase.Submitting) { + CircularProgressIndicator() + } else if (phase !is CapturePhase.Done) { + WtPrimaryButton( + text = stringResource(actionRes), + enabled = faceReady && phase != CapturePhase.Submitting, + modifier = Modifier.fillMaxWidth(), + onClick = { + phase = CapturePhase.Submitting + imageCapture.takePicture( + ContextCompat.getMainExecutor(context), + object : ImageCapture.OnImageCapturedCallback() { + override fun onCaptureSuccess(image: ImageProxy) { + val bitmap = image.toUprightBitmap() + image.close() + scope.launch { + phase = try { + val embedding = withContext(Dispatchers.Default) { + // Crop to the detected face first — MobileFaceNet + // needs a tight face crop, not the whole frame, + // or embeddings won't match on verification. + val faceCrop = cropToFace(bitmap) + embedder.embed(faceCrop).toList() + } + when (val result = onSubmit(embedding)) { + is FaceCaptureResult.Success -> CapturePhase.Done(result) + else -> CapturePhase.Failed(failureMessageOf(result)) + } + } catch (_: FaceModelUnavailableException) { + CapturePhase.Failed(R.string.att_face_enroll_no_model) + } catch (_: Exception) { + CapturePhase.Failed(R.string.att_face_enroll_error) + } + } + } + + override fun onError(exception: ImageCaptureException) { + phase = CapturePhase.Failed(R.string.att_face_enroll_error) + } + }, + ) + }, + ) + } + } + } +} + +// ImageProxy.getImage() is CameraX-experimental; ML Kit's own docs use it. +@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class) +private fun detectFace( + proxy: ImageProxy, + detector: com.google.mlkit.vision.face.FaceDetector, + onResult: (Boolean) -> Unit, +) { + val mediaImage = proxy.image + if (mediaImage == null) { + proxy.close() + return + } + val input = InputImage.fromMediaImage(mediaImage, proxy.imageInfo.rotationDegrees) + detector.process(input) + .addOnSuccessListener { faces -> onResult(faces.size == 1) } + .addOnCompleteListener { proxy.close() } +} + +/** Captured proxy → a rotation-corrected Bitmap for the embedder. */ +private fun ImageProxy.toUprightBitmap(): Bitmap { + val raw = toBitmap() + val degrees = imageInfo.rotationDegrees + if (degrees == 0) return raw + val matrix = Matrix().apply { postRotate(degrees.toFloat()) } + return Bitmap.createBitmap(raw, 0, 0, raw.width, raw.height, matrix, true) +} + +/** + * Detects the largest face in [frame] and returns a tight (margined) crop of it, + * so the embedder sees just the face. Falls back to a centered square when no + * face is found. Uses its own detector instance to avoid clashing with the + * live preview analyzer. + */ +private suspend fun cropToFace(frame: Bitmap): Bitmap { + val detector = FaceDetection.getClient( + FaceDetectorOptions.Builder() + .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE) + .build(), + ) + return try { + val box: Rect? = suspendCancellableCoroutine { cont -> + detector.process(InputImage.fromBitmap(frame, 0)) + .addOnSuccessListener { faces -> + cont.resume(faces.maxByOrNull { it.boundingBox.width() * it.boundingBox.height() }?.boundingBox) + } + .addOnFailureListener { cont.resume(null) } + } + if (box != null) frame.cropWithMargin(box) else frame.centerSquare() + } finally { + detector.close() + } +} + +/** Crops [box] expanded by ~25% on each side, clamped to the bitmap bounds. */ +private fun Bitmap.cropWithMargin(box: Rect): Bitmap { + val marginX = (box.width() * 0.25f).toInt() + val marginY = (box.height() * 0.25f).toInt() + val left = (box.left - marginX).coerceIn(0, width - 1) + val top = (box.top - marginY).coerceIn(0, height - 1) + val right = (box.right + marginX).coerceIn(left + 1, width) + val bottom = (box.bottom + marginY).coerceIn(top + 1, height) + return Bitmap.createBitmap(this, left, top, right - left, bottom - top) +} + +private fun Bitmap.centerSquare(): Bitmap { + val size = minOf(width, height) + return Bitmap.createBitmap(this, (width - size) / 2, (height - size) / 2, size, size) +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/face/FaceEnrollViewModel.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/face/FaceEnrollViewModel.kt new file mode 100644 index 0000000..0e3856f --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/face/FaceEnrollViewModel.kt @@ -0,0 +1,37 @@ +package app.worktrack.feature.attendance.face + +import androidx.lifecycle.ViewModel +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.domain.repository.FaceRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class FaceEnrollViewModel @Inject constructor( + private val faceRepository: FaceRepository, +) : ViewModel() { + + /** + * Submits the on-device embedding for enrollment. Enrolling twice is refused + * by the server (an admin must reset first), which is reported distinctly so + * the employee knows to ask rather than keep retrying. + */ + suspend fun enroll(embedding: List): FaceCaptureResult = + when (val result = faceRepository.enroll(embedding)) { + is AppResult.Success -> FaceCaptureResult.Success() + is AppResult.Failure -> { + val error = result.error + if (error is AppError.Business && error.code == CODE_ALREADY_ENROLLED) { + FaceCaptureResult.AlreadyEnrolled + } else { + FaceCaptureResult.Failed + } + } + } + + private companion object { + /** Mirrors the server's ErrorCodes.FACE_ALREADY_ENROLLED. */ + const val CODE_ALREADY_ENROLLED = "FACE_ALREADY_ENROLLED" + } +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/face/FaceVerifyViewModel.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/face/FaceVerifyViewModel.kt new file mode 100644 index 0000000..fc06d29 --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/face/FaceVerifyViewModel.kt @@ -0,0 +1,37 @@ +package app.worktrack.feature.attendance.face + +import androidx.lifecycle.ViewModel +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.domain.repository.FaceRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class FaceVerifyViewModel @Inject constructor( + private val faceRepository: FaceRepository, +) : ViewModel() { + + /** + * Submits the on-device embedding. A rejected face, a missing enrollment and + * a failed request are reported separately so the screen can say which one + * happened instead of a blanket "try again". + */ + suspend fun verify(embedding: List): FaceCaptureResult = + when (val result = faceRepository.verify(embedding)) { + is AppResult.Success -> { + val verification = result.data + when { + !verification.enrolled -> FaceCaptureResult.NotEnrolled + // A match goes through even when the server sent no token + // (an older deployment that predates them). Refusing here + // would block a check-in the server just confirmed, and it + // would buy nothing: only the server decides faceVerified, + // so a tokenless punch is simply recorded as unverified. + verification.match -> FaceCaptureResult.Success(verification.token) + else -> FaceCaptureResult.NoMatch + } + } + + is AppResult.Failure -> FaceCaptureResult.Failed + } +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryScreen.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryScreen.kt new file mode 100644 index 0000000..6cfc6f1 --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryScreen.kt @@ -0,0 +1,375 @@ +package app.worktrack.feature.attendance.history + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.EditCalendar +import androidx.compose.material.icons.filled.EventBusy +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.ChipTone +import app.worktrack.core.designsystem.component.EmptyState +import app.worktrack.core.designsystem.component.StatusChip +import app.worktrack.core.designsystem.component.WtTextField +import app.worktrack.core.designsystem.component.WtTopBar +import app.worktrack.core.designsystem.l10n.formatShamsiDate +import app.worktrack.core.designsystem.l10n.formatShamsiMonthYear +import app.worktrack.core.designsystem.l10n.localizedDigits +import app.worktrack.core.designsystem.l10n.localizedMessage +import app.worktrack.core.model.AttendanceDay +import app.worktrack.core.model.AttendanceDayStatus +import app.worktrack.feature.attendance.R +import java.time.LocalDate +import java.time.LocalTime + +@Composable +fun AttendanceHistoryRoute( + onBack: () -> Unit, + viewModel: AttendanceHistoryViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val submitting by viewModel.submitting.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + // The day whose correction dialog is open, if any. + var correctionDay by remember { mutableStateOf(null) } + val submittedMsg = stringResource(R.string.reg_submitted) + + LaunchedEffect(Unit) { + viewModel.effects.collect { effect -> + when (effect) { + RegularizationEffect.Submitted -> { + correctionDay = null + snackbarHostState.showSnackbar(submittedMsg) + } + is RegularizationEffect.Failed -> + snackbarHostState.showSnackbar(effect.error.localizedMessage(context)) + } + } + } + + Scaffold( + topBar = { WtTopBar(title = stringResource(R.string.att_history_title), onBack = onBack) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column(Modifier.padding(padding)) { + MonthSelector( + label = formatShamsiMonthYear(state.shamsiYear, state.shamsiMonth), + canGoForward = state.canGoForward, + onPrevious = viewModel::onPreviousMonth, + onNext = viewModel::onNextMonth, + ) + if (state.days.isEmpty()) { + EmptyState( + icon = Icons.Filled.EventBusy, + title = stringResource(R.string.att_history_empty_title), + message = stringResource(R.string.att_history_empty_msg), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.days, key = { it.id }) { day -> + DayCard(day = day, onRequestCorrection = { correctionDay = day }) + } + } + } + } + } + + correctionDay?.let { day -> + RegularizationDialog( + date = day.date, + submitting = submitting, + onDismiss = { if (!submitting) correctionDay = null }, + onSubmit = { inTime, outTime, reason -> + viewModel.submitRegularization(day.date, inTime, outTime, reason) + }, + ) + } +} + +@Composable +private fun MonthSelector( + label: String, + canGoForward: Boolean, + onPrevious: () -> Unit, + onNext: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onPrevious) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = stringResource(R.string.att_prev_month), + ) + } + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + ) + IconButton(onClick = onNext, enabled = canGoForward) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = stringResource(R.string.att_next_month), + ) + } + } +} + +@Composable +private fun DayCard(day: AttendanceDay, onRequestCorrection: () -> Unit) { + Card( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Row( + Modifier.padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = formatShamsiDate(day.date, withWeekday = true), + style = MaterialTheme.typography.titleSmall, + ) + if (day.workedMinutes > 0) { + val worked = localizedDigits( + stringResource( + R.string.att_worked_short, + (day.workedMinutes / 60).toString(), + (day.workedMinutes % 60).toString(), + ), + ) + val overtime = if (day.overtimeMinutes > 0) { + " · " + localizedDigits( + stringResource( + R.string.att_overtime_short, + day.overtimeMinutes.toString(), + ), + ) + } else { + "" + } + Text( + text = worked + overtime, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (day.lateMinutes > 0) { + Text( + text = localizedDigits( + stringResource(R.string.att_late_by, day.lateMinutes.toString()), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + StatusChip(text = day.status.label(), tone = day.status.tone()) + IconButton(onClick = onRequestCorrection) { + Icon( + Icons.Filled.EditCalendar, + contentDescription = stringResource(R.string.reg_request), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } +} + +/** + * Correction request dialog: the employee proposes a corrected clock-in and/or + * clock-out for [date] plus a reason. Times default to unset; at least one plus + * a reason is required (enforced here for immediate feedback and server-side). + */ +@Composable +private fun RegularizationDialog( + date: LocalDate, + submitting: Boolean, + onDismiss: () -> Unit, + onSubmit: (inTime: LocalTime?, outTime: LocalTime?, reason: String) -> Unit, +) { + var inTime by remember { mutableStateOf(null) } + var outTime by remember { mutableStateOf(null) } + var reason by remember { mutableStateOf("") } + var picking by remember { mutableStateOf(null) } + + val canSubmit = (inTime != null || outTime != null) && reason.isNotBlank() && !submitting + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.reg_dialog_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = formatShamsiDate(date, withYear = true), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TimeRow( + label = stringResource(R.string.reg_in_time), + time = inTime, + onClick = { picking = TimeTarget.IN }, + ) + TimeRow( + label = stringResource(R.string.reg_out_time), + time = outTime, + onClick = { picking = TimeTarget.OUT }, + ) + Spacer(Modifier.height(4.dp)) + WtTextField( + value = reason, + onValueChange = { reason = it }, + label = stringResource(R.string.reg_reason), + modifier = Modifier.fillMaxWidth(), + singleLine = false, + ) + } + }, + confirmButton = { + TextButton( + enabled = canSubmit, + onClick = { onSubmit(inTime, outTime, reason) }, + ) { Text(stringResource(R.string.reg_submit)) } + }, + dismissButton = { + TextButton(enabled = !submitting, onClick = onDismiss) { + Text(stringResource(R.string.reg_cancel)) + } + }, + ) + + picking?.let { target -> + val current = when (target) { + TimeTarget.IN -> inTime + TimeTarget.OUT -> outTime + } ?: LocalTime.of(9, 0) + TimePickerDialog( + initial = current, + onDismiss = { picking = null }, + onConfirm = { picked -> + when (target) { + TimeTarget.IN -> inTime = picked + TimeTarget.OUT -> outTime = picked + } + picking = null + }, + ) + } +} + +@Composable +private fun TimeRow(label: String, time: LocalTime?, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = label, style = MaterialTheme.typography.bodyMedium) + AssistChip( + onClick = onClick, + label = { + Text( + time?.let { localizedDigits("%02d:%02d".format(it.hour, it.minute)) } + ?: stringResource(R.string.reg_set_time), + ) + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TimePickerDialog( + initial: LocalTime, + onDismiss: () -> Unit, + onConfirm: (LocalTime) -> Unit, +) { + val pickerState = rememberTimePickerState( + initialHour = initial.hour, + initialMinute = initial.minute, + is24Hour = true, + ) + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { onConfirm(LocalTime.of(pickerState.hour, pickerState.minute)) }, + ) { Text(stringResource(app.worktrack.core.designsystem.R.string.ds_ok)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(app.worktrack.core.designsystem.R.string.ds_cancel)) + } + }, + text = { TimePicker(state = pickerState) }, + ) +} + +private enum class TimeTarget { IN, OUT } + +@Composable +private fun AttendanceDayStatus.label(): String = stringResource( + when (this) { + AttendanceDayStatus.PRESENT -> R.string.att_status_present + AttendanceDayStatus.ABSENT -> R.string.att_status_absent + AttendanceDayStatus.HALF_DAY -> R.string.att_status_half_day + AttendanceDayStatus.LEAVE -> R.string.att_status_leave + AttendanceDayStatus.HOLIDAY -> R.string.att_status_holiday + AttendanceDayStatus.WEEK_OFF -> R.string.att_status_week_off + AttendanceDayStatus.PENDING -> R.string.att_status_pending + }, +) + +private fun AttendanceDayStatus.tone(): ChipTone = when (this) { + AttendanceDayStatus.PRESENT -> ChipTone.POSITIVE + AttendanceDayStatus.ABSENT -> ChipTone.NEGATIVE + AttendanceDayStatus.HALF_DAY, AttendanceDayStatus.PENDING -> ChipTone.WARNING + else -> ChipTone.NEUTRAL +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryViewModel.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryViewModel.kt new file mode 100644 index 0000000..f9808e7 --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/history/AttendanceHistoryViewModel.kt @@ -0,0 +1,158 @@ +package app.worktrack.feature.attendance.history + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.time.SolarHijri +import app.worktrack.core.common.time.SolarHijriDate +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.domain.repository.AttendanceRepository +import app.worktrack.core.domain.usecase.attendance.RequestRegularizationUseCase +import app.worktrack.core.model.AttendanceDay +import app.worktrack.core.model.RegularizationCommand +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.LocalDate +import java.time.LocalTime +import javax.inject.Inject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Attendance history paged by **Solar Hijri** months — the business calendar + * of the platform. The Room query range is the Gregorian projection of the + * selected Shamsi month. + */ +data class AttendanceHistoryUiState( + val shamsiYear: Int, + val shamsiMonth: Int, + val days: List, + val canGoForward: Boolean, +) + +/** One-shot outcomes of filing an attendance correction, surfaced as a snackbar. */ +sealed interface RegularizationEffect { + data object Submitted : RegularizationEffect + data class Failed(val error: AppError) : RegularizationEffect +} + +@HiltViewModel +class AttendanceHistoryViewModel @Inject constructor( + attendanceRepository: AttendanceRepository, + private val requestRegularization: RequestRegularizationUseCase, + private val timeProvider: TimeProvider, + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private fun currentShamsiMonth(): SolarHijriDate = SolarHijri.today(timeProvider) + + // "1405-04" — survives process death. + private val monthKey: StateFlow = savedStateHandle.getStateFlow( + KEY_MONTH, + currentShamsiMonth().monthKey(), + ) + + val uiState: StateFlow = monthKey + .map(::parseKey) + .flatMapLatest { (year, month) -> + attendanceRepository + .observeDays( + from = SolarHijri.monthStart(year, month), + to = SolarHijri.monthEnd(year, month), + ) + .map { days -> + val today = currentShamsiMonth() + AttendanceHistoryUiState( + shamsiYear = year, + shamsiMonth = month, + days = days, + canGoForward = year < today.year || + (year == today.year && month < today.month), + ) + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = parseKey(monthKey.value).let { (year, month) -> + AttendanceHistoryUiState(year, month, emptyList(), canGoForward = false) + }, + ) + + private val _effects = Channel(Channel.BUFFERED) + val effects = _effects.receiveAsFlow() + + private val _submitting = MutableStateFlow(false) + val submitting: StateFlow = _submitting.asStateFlow() + + /** + * Files a correction for [date]. Times are the employee's local wall-clock + * for that day, anchored to the company time zone before being sent as UTC + * instants — so 8:30 always means 8:30 in Kabul regardless of device zone. + */ + fun submitRegularization( + date: LocalDate, + inTime: LocalTime?, + outTime: LocalTime?, + reason: String, + ) { + if (_submitting.value) return + _submitting.update { true } + viewModelScope.launch { + val zone = timeProvider.zone() + val command = RegularizationCommand( + date = date, + requestedInAt = inTime?.let { date.atTime(it).atZone(zone).toInstant() }, + requestedOutAt = outTime?.let { date.atTime(it).atZone(zone).toInstant() }, + reason = reason, + ) + val effect = when (val result = requestRegularization(command)) { + is AppResult.Success -> RegularizationEffect.Submitted + is AppResult.Failure -> RegularizationEffect.Failed(result.error) + } + _effects.send(effect) + _submitting.update { false } + } + } + + fun onPreviousMonth() = shiftMonth(-1) + + fun onNextMonth() = shiftMonth(+1) + + private fun shiftMonth(delta: Int) { + val (year, month) = parseKey(monthKey.value) + var targetYear = year + var targetMonth = month + delta + if (targetMonth < 1) { + targetMonth = 12 + targetYear -= 1 + } else if (targetMonth > 12) { + targetMonth = 1 + targetYear += 1 + } + val today = currentShamsiMonth() + val beyondCurrent = targetYear > today.year || + (targetYear == today.year && targetMonth > today.month) + if (beyondCurrent) return + savedStateHandle[KEY_MONTH] = SolarHijriDate(targetYear, targetMonth, 1).monthKey() + } + + private fun parseKey(key: String): Pair { + val (year, month) = key.split("-").map(String::toInt) + return year to month + } + + private companion object { + const val KEY_MONTH = "shamsiMonth" + } +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/location/LocationClient.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/location/LocationClient.kt new file mode 100644 index 0000000..c1fa5d1 --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/location/LocationClient.kt @@ -0,0 +1,59 @@ +package app.worktrack.feature.attendance.location + +import android.Manifest +import android.annotation.SuppressLint +import android.os.Build +import com.google.android.gms.location.CurrentLocationRequest +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.Priority +import com.google.android.gms.tasks.CancellationTokenSource +import javax.inject.Inject +import kotlinx.coroutines.tasks.await + +data class DeviceLocation( + val latitude: Double, + val longitude: Double, + val accuracyMeters: Float, + val isMock: Boolean, +) + +/** + * One-shot high-accuracy location fix for punching. Callers must hold + * ACCESS_FINE_LOCATION before invoking; the screen gates on the permission. + */ +class LocationClient @Inject constructor( + private val fusedClient: FusedLocationProviderClient, +) { + + @SuppressLint("MissingPermission") + @androidx.annotation.RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) + suspend fun currentLocation(): DeviceLocation? { + val request = CurrentLocationRequest.Builder() + .setPriority(Priority.PRIORITY_HIGH_ACCURACY) + .setDurationMillis(TIMEOUT_MILLIS) + .setMaxUpdateAgeMillis(MAX_AGE_MILLIS) + .build() + + val location = fusedClient + .getCurrentLocation(request, CancellationTokenSource().token) + .await() ?: return null + + val isMock = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + location.isMock + } else { + @Suppress("DEPRECATION") + location.isFromMockProvider + } + return DeviceLocation( + latitude = location.latitude, + longitude = location.longitude, + accuracyMeters = location.accuracy, + isMock = isMock, + ) + } + + private companion object { + const val TIMEOUT_MILLIS = 15_000L + const val MAX_AGE_MILLIS = 10_000L // a stale fix is worse than a short wait + } +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/navigation/AttendanceNavigation.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/navigation/AttendanceNavigation.kt new file mode 100644 index 0000000..465e355 --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/navigation/AttendanceNavigation.kt @@ -0,0 +1,119 @@ +package app.worktrack.feature.attendance.navigation + +import androidx.compose.runtime.LaunchedEffect +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import app.worktrack.feature.attendance.face.FaceEnrollRoute +import app.worktrack.feature.attendance.face.FaceEnrollViewModel +import app.worktrack.feature.attendance.face.FaceVerifyRoute +import app.worktrack.feature.attendance.face.FaceVerifyViewModel +import app.worktrack.feature.attendance.history.AttendanceHistoryRoute +import app.worktrack.feature.attendance.punch.PunchRoute +import app.worktrack.feature.attendance.punch.PunchViewModel +import app.worktrack.feature.attendance.qr.QrScanRoute +import kotlinx.coroutines.launch + +/** + * Keys for results the QR and face screens hand back to the punch screen. + * + * They live on the punch NavBackStackEntry's own SavedStateHandle, which is a + * different object from the one Hilt injects into a ViewModel scoped to that + * entry — so the punch screen must read them from the entry itself (see below) + * and forward them to the ViewModel explicitly. + */ +private const val KEY_KIOSK_TOKEN = "kioskToken" +private const val KEY_FACE_TOKEN = "faceToken" +private const val KEY_FACE_VERIFIED = "faceVerified" + +const val PUNCH_ROUTE = "attendance/punch" +const val QR_SCAN_ROUTE = "attendance/qr-scan" +const val ATTENDANCE_HISTORY_ROUTE = "attendance/history" +const val FACE_ENROLL_ROUTE = "attendance/face-enroll" +const val FACE_VERIFY_ROUTE = "attendance/face-verify" + +fun NavGraphBuilder.attendanceScreens(navController: NavController) { + composable(route = PUNCH_ROUTE) { entry -> + val viewModel: PunchViewModel = hiltViewModel() + + // Read the results off the same handle the other screens wrote to, then + // hand them to the ViewModel. Each is cleared once consumed so that + // returning to this screen later cannot replay an old check-in. + LaunchedEffect(entry) { + launch { + entry.savedStateHandle + .getStateFlow(KEY_KIOSK_TOKEN, null) + .collect { token -> + if (!token.isNullOrBlank()) { + entry.savedStateHandle[KEY_KIOSK_TOKEN] = null + viewModel.onKioskTokenScanned(token) + } + } + } + launch { + entry.savedStateHandle + .getStateFlow(KEY_FACE_VERIFIED, false) + .collect { verified -> + if (verified) { + val token: String? = entry.savedStateHandle[KEY_FACE_TOKEN] + entry.savedStateHandle[KEY_FACE_VERIFIED] = false + entry.savedStateHandle[KEY_FACE_TOKEN] = null + viewModel.onFaceVerified(token) + } + } + } + } + + PunchRoute( + onBack = { navController.popBackStack() }, + onScanQr = { navController.navigate(QR_SCAN_ROUTE) }, + onVerifyFace = { navController.navigate(FACE_VERIFY_ROUTE) }, + onEnrollFace = { navController.navigate(FACE_ENROLL_ROUTE) }, + viewModel = viewModel, + ) + } + + composable(route = QR_SCAN_ROUTE) { + QrScanRoute( + onBack = { navController.popBackStack() }, + onTokenScanned = { token -> + // Hand the token to the punch screen's SavedStateHandle and pop. + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(KEY_KIOSK_TOKEN, token) + navController.popBackStack() + }, + ) + } + + composable(route = ATTENDANCE_HISTORY_ROUTE) { + AttendanceHistoryRoute(onBack = { navController.popBackStack() }) + } + + composable(route = FACE_ENROLL_ROUTE) { + val viewModel: FaceEnrollViewModel = hiltViewModel() + FaceEnrollRoute( + onBack = { navController.popBackStack() }, + onSubmit = { embedding -> viewModel.enroll(embedding) }, + onDone = { navController.popBackStack() }, + ) + } + + composable(route = FACE_VERIFY_ROUTE) { + val viewModel: FaceVerifyViewModel = hiltViewModel() + FaceVerifyRoute( + onBack = { navController.popBackStack() }, + onSubmit = { embedding -> viewModel.verify(embedding) }, + onDone = { success -> + // Hand the result to the punch screen: the token when the server + // issued one, and always the flag that completes the check-in. + navController.previousBackStackEntry?.savedStateHandle?.apply { + set(KEY_FACE_TOKEN, success.token) + set(KEY_FACE_VERIFIED, true) + } + navController.popBackStack() + }, + ) + } +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchScreen.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchScreen.kt new file mode 100644 index 0000000..aba1cff --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchScreen.kt @@ -0,0 +1,285 @@ +package app.worktrack.feature.attendance.punch + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.ChipTone +import app.worktrack.core.designsystem.component.StatusChip +import app.worktrack.core.designsystem.component.WtPrimaryButton +import app.worktrack.core.designsystem.component.WtSecondaryButton +import app.worktrack.core.designsystem.component.WtTopBar +import app.worktrack.core.designsystem.l10n.localizedDigits +import app.worktrack.core.designsystem.l10n.localizedMessage +import app.worktrack.core.model.PunchType +import app.worktrack.feature.attendance.R + +private val LOCATION_PERMISSIONS = arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, +) + +@Composable +fun PunchRoute( + onBack: () -> Unit, + onScanQr: () -> Unit, + onVerifyFace: () -> Unit, + onEnrollFace: () -> Unit, + viewModel: PunchViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val today by viewModel.today.collectAsStateWithLifecycle() + val faceRequired by viewModel.faceRequired.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + // FINE and COARSE must be asked for together. An app targeting SDK 31 or + // later that requests FINE on its own has the request dropped outright: + // no dialog appears and nothing is granted, so check-in would simply never + // get a location on Android 12 and every version since. targetSdk is 35. + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { grants -> + // Coarse alone is enough to punch — the geofence radius is far wider + // than its error. Refusing a check-in because the employee granted + // "approximate" would be a worse outcome than a slightly vaguer point. + val granted = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true || + grants[Manifest.permission.ACCESS_COARSE_LOCATION] == true + if (granted) viewModel.onLocationPermissionGranted() else viewModel.onLocationPermissionDenied() + } + + LaunchedEffect(Unit) { + val granted = LOCATION_PERMISSIONS.any { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + if (granted) { + viewModel.onLocationPermissionGranted() + } else { + permissionLauncher.launch(LOCATION_PERMISSIONS) + } + } + + LaunchedEffect(Unit) { + viewModel.effects.collect { effect -> + when (effect) { + is PunchEffect.Failed -> + snackbarHostState.showSnackbar(effect.error.localizedMessage(context)) + + is PunchEffect.LocationLost -> snackbarHostState.showSnackbar( + context.getString(R.string.att_punch_location_lost), + ) + + is PunchEffect.PunchRecorded -> snackbarHostState.showSnackbar( + context.getString( + if (effect.type == PunchType.IN) { + R.string.att_punch_in_recorded + } else { + R.string.att_punch_out_recorded + }, + ), + ) + } + } + } + + Scaffold( + topBar = { WtTopBar(title = stringResource(R.string.att_punch_title), onBack = onBack) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + PunchScreen( + state = state, + clockedIn = today?.clockedIn == true, + // Plain GPS check-in always works; face verification is a separate, + // optional action so a finicky match never blocks attendance. + onPunch = viewModel::onPunch, + onRetryLocation = { + permissionLauncher.launch(LOCATION_PERMISSIONS) + }, + onScanQr = onScanQr, + faceRequired = faceRequired, + onVerifyFace = onVerifyFace, + onEnrollFace = onEnrollFace, + modifier = Modifier.padding(padding), + ) + } +} + +@Composable +internal fun PunchScreen( + state: PunchUiState, + clockedIn: Boolean, + onPunch: () -> Unit, + onRetryLocation: () -> Unit, + onScanQr: () -> Unit, + faceRequired: Boolean = false, + onVerifyFace: () -> Unit = {}, + onEnrollFace: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LocationStatusCard(state.location, onRetryLocation) + + Spacer(Modifier.height(32.dp)) + + WtPrimaryButton( + text = stringResource(if (clockedIn) R.string.att_clock_out else R.string.att_clock_in), + onClick = onPunch, + modifier = Modifier.fillMaxWidth(), + enabled = state.location is LocationUiState.Ready, + loading = state.isPunching, + ) + + Spacer(Modifier.height(16.dp)) + + WtSecondaryButton( + text = stringResource(R.string.att_scan_qr), + onClick = onScanQr, + modifier = Modifier.fillMaxWidth(), + ) + + if (faceRequired) { + Spacer(Modifier.height(16.dp)) + WtSecondaryButton( + text = stringResource(R.string.att_face_verify_title), + onClick = onVerifyFace, + modifier = Modifier.fillMaxWidth(), + enabled = state.location is LocationUiState.Ready, + ) + Spacer(Modifier.height(16.dp)) + WtSecondaryButton( + text = stringResource(R.string.att_face_enroll_title), + onClick = onEnrollFace, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun LocationStatusCard( + location: LocationUiState, + onRetry: () -> Unit, +) { + Card(Modifier.fillMaxWidth()) { + Column( + Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (location) { + LocationUiState.PermissionRequired -> { + Text( + text = stringResource(R.string.att_location_permission_needed), + style = MaterialTheme.typography.titleMedium, + ) + } + + LocationUiState.Acquiring -> { + Text( + text = stringResource(R.string.att_getting_location), + style = MaterialTheme.typography.titleMedium, + ) + } + + is LocationUiState.Ready -> { + val evaluation = location.evaluation + when { + !evaluation.fencesConfigured -> StatusChip( + stringResource(R.string.att_no_geofence), + ChipTone.NEUTRAL, + ) + + evaluation.insideFence -> StatusChip( + stringResource( + R.string.att_inside_fence, + evaluation.nearestFence?.name.orEmpty(), + ), + ChipTone.POSITIVE, + ) + + else -> StatusChip( + localizedDigits( + stringResource( + R.string.att_outside_fence, + (evaluation.distanceMeters?.toInt() ?: 0).toString(), + ), + ), + ChipTone.NEGATIVE, + ) + } + Spacer(Modifier.height(8.dp)) + Text( + text = localizedDigits( + stringResource( + R.string.att_accuracy, + location.location.accuracyMeters.toInt().toString(), + ), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + is LocationUiState.Unavailable -> { + Text( + text = stringResource( + when (location.reason) { + LocationUnavailableReason.PERMISSION_DENIED -> + R.string.att_loc_unavailable_permission + + LocationUnavailableReason.NO_FIX -> + R.string.att_loc_unavailable_no_fix + }, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(8.dp)) + WtSecondaryButton( + text = stringResource(app.worktrack.core.designsystem.R.string.ds_retry), + onClick = onRetry, + ) + } + } + Spacer(Modifier.height(8.dp)) + Icon( + imageVector = Icons.Filled.QrCodeScanner, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchViewModel.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchViewModel.kt new file mode 100644 index 0000000..eba3829 --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/punch/PunchViewModel.kt @@ -0,0 +1,228 @@ +package app.worktrack.feature.attendance.punch + +import android.Manifest +import androidx.annotation.RequiresPermission +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.domain.usecase.attendance.EvaluateGeofenceUseCase +import app.worktrack.core.domain.usecase.attendance.GeofenceEvaluation +import app.worktrack.core.domain.usecase.attendance.ObserveTodayAttendanceUseCase +import app.worktrack.core.domain.usecase.attendance.PunchClockUseCase +import app.worktrack.core.domain.usecase.auth.ObserveSessionUseCase +import app.worktrack.core.model.PunchCommand +import app.worktrack.core.model.PunchMethod +import app.worktrack.core.model.PunchType +import app.worktrack.core.model.TodayAttendance +import app.worktrack.feature.attendance.location.DeviceLocation +import app.worktrack.feature.attendance.location.LocationClient +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +enum class LocationUnavailableReason { PERMISSION_DENIED, NO_FIX } + +sealed interface LocationUiState { + data object PermissionRequired : LocationUiState + data object Acquiring : LocationUiState + data class Ready( + val location: DeviceLocation, + val evaluation: GeofenceEvaluation, + ) : LocationUiState + + data class Unavailable(val reason: LocationUnavailableReason) : LocationUiState +} + +data class PunchUiState( + val location: LocationUiState = LocationUiState.PermissionRequired, + val isPunching: Boolean = false, +) + +sealed interface PunchEffect { + /** Localized by the UI via AppError.localizedMessage(). */ + data class Failed(val error: AppError) : PunchEffect + data class PunchRecorded(val type: PunchType) : PunchEffect + + /** Verification succeeded but the location fix was gone by the time we punched. */ + data object LocationLost : PunchEffect +} + +@HiltViewModel +class PunchViewModel @Inject constructor( + observeToday: ObserveTodayAttendanceUseCase, + observeSession: ObserveSessionUseCase, + private val punchClock: PunchClockUseCase, + private val evaluateGeofence: EvaluateGeofenceUseCase, + private val locationClient: LocationClient, +) : ViewModel() { + + val today: StateFlow = observeToday() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** Whether the company requires a check-in selfie (feature flag). */ + val faceRequired: StateFlow = observeSession() + .map { it?.features?.faceRecognition == true } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + private val _uiState = MutableStateFlow(PunchUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _effects = Channel(Channel.BUFFERED) + val effects = _effects.receiveAsFlow() + + /** + * A kiosk QR code was scanned; complete the punch with it. + * + * Results come in through explicit calls rather than this ViewModel reading + * a SavedStateHandle: the handle a navigation result is written to (the + * NavBackStackEntry's own) is a different object from the one injected + * here, so a value set there would never arrive. + */ + fun onKioskTokenScanned(kioskToken: String) = punchWithQr(kioskToken) + + /** + * The server confirmed the employee's face. [faceToken] is its signed proof + * when the backend issued one; the punch still goes through without it and + * is simply recorded as unverified. + */ + fun onFaceVerified(faceToken: String?) = punchWithFace(faceToken) + + /** Invoked by the screen once ACCESS_FINE_LOCATION is granted. */ + @RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION) + fun onLocationPermissionGranted() { + if (_uiState.value.location is LocationUiState.Acquiring) return + _uiState.update { it.copy(location = LocationUiState.Acquiring) } + + viewModelScope.launch { + val location = try { + locationClient.currentLocation() + } catch (_: SecurityException) { + null + } + if (location == null) { + _uiState.update { + it.copy( + location = LocationUiState.Unavailable(LocationUnavailableReason.NO_FIX), + ) + } + } else { + val evaluation = + evaluateGeofence(location.latitude, location.longitude, location.accuracyMeters) + _uiState.update { it.copy(location = LocationUiState.Ready(location, evaluation)) } + } + } + } + + fun onLocationPermissionDenied() { + _uiState.update { + it.copy( + location = LocationUiState.Unavailable(LocationUnavailableReason.PERMISSION_DENIED), + ) + } + } + + fun onPunch() { + val ready = _uiState.value.location as? LocationUiState.Ready ?: return + val nextType = nextPunchType() ?: return + submit( + PunchCommand( + type = nextType, + method = PunchMethod.GPS, + latitude = ready.location.latitude, + longitude = ready.location.longitude, + accuracyMeters = ready.location.accuracyMeters, + isMockLocation = ready.location.isMock, + ), + ) + } + + /** + * Punch verified by face recognition, carrying the server's signed proof. + * + * The user has just been told "face verified", so a location that went stale + * in the meantime must be reported — silently doing nothing would leave them + * believing they had checked in. + */ + private fun punchWithFace(faceToken: String?) { + viewModelScope.launch { + // Coming back from the camera restarts the location fix, so it is + // usually still arriving at this moment. Wait for it rather than + // discarding a verification the user has just completed — and only + // report failure if no fix turns up at all. + val ready = withTimeoutOrNull(LOCATION_WAIT_MS) { + _uiState + .map { it.location } + .filterIsInstance() + .first() + } + if (ready == null) { + _effects.send(PunchEffect.LocationLost) + return@launch + } + val nextType = nextPunchType() ?: return@launch + submit( + PunchCommand( + type = nextType, + method = PunchMethod.FACE, + latitude = ready.location.latitude, + longitude = ready.location.longitude, + accuracyMeters = ready.location.accuracyMeters, + isMockLocation = ready.location.isMock, + faceToken = faceToken, + ), + ) + } + } + + private fun punchWithQr(kioskToken: String) { + val nextType = nextPunchType() ?: return + val ready = _uiState.value.location as? LocationUiState.Ready + submit( + PunchCommand( + type = nextType, + method = PunchMethod.QR, + latitude = ready?.location?.latitude, + longitude = ready?.location?.longitude, + accuracyMeters = ready?.location?.accuracyMeters, + isMockLocation = ready?.location?.isMock ?: false, + kioskToken = kioskToken, + ), + ) + } + + private fun submit(command: PunchCommand) { + if (_uiState.value.isPunching) return + _uiState.update { it.copy(isPunching = true) } + viewModelScope.launch { + when (val result = punchClock(command)) { + is AppResult.Success -> + _effects.send(PunchEffect.PunchRecorded(command.type)) + + is AppResult.Failure -> + _effects.send(PunchEffect.Failed(result.error)) + } + _uiState.update { it.copy(isPunching = false) } + } + } + + private fun nextPunchType(): PunchType? = + today.value?.let { if (it.clockedIn) PunchType.OUT else PunchType.IN } + + private companion object { + /** How long a face-verified punch waits for a GPS fix before giving up. */ + const val LOCATION_WAIT_MS = 15_000L + } +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/qr/QrScanScreen.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/qr/QrScanScreen.kt new file mode 100644 index 0000000..9f85622 --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/qr/QrScanScreen.kt @@ -0,0 +1,173 @@ +package app.worktrack.feature.attendance.qr + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import app.worktrack.feature.attendance.R +import androidx.core.content.ContextCompat +import app.worktrack.core.designsystem.component.EmptyState +import app.worktrack.core.designsystem.component.WtTopBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.NoPhotography +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Full-screen kiosk QR scanner. Fires [onTokenScanned] exactly once with the + * raw QR payload (the signed kiosk TOTP token) and expects the caller to pop. + */ +@Composable +fun QrScanRoute( + onBack: () -> Unit, + onTokenScanned: (String) -> Unit, +) { + val context = LocalContext.current + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED, + ) + } + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> hasCameraPermission = granted } + + androidx.compose.runtime.LaunchedEffect(Unit) { + if (!hasCameraPermission) permissionLauncher.launch(Manifest.permission.CAMERA) + } + + Scaffold( + topBar = { WtTopBar(title = stringResource(R.string.att_qr_title), onBack = onBack) }, + ) { padding -> + if (hasCameraPermission) { + CameraQrScanner( + onTokenScanned = onTokenScanned, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) + } else { + EmptyState( + icon = Icons.Filled.NoPhotography, + title = stringResource(R.string.att_qr_camera_permission_title), + message = stringResource(R.string.att_qr_camera_permission_msg), + modifier = Modifier.padding(padding), + ) + } + } +} + +@Composable +private fun CameraQrScanner( + onTokenScanned: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val analysisExecutor = remember { Executors.newSingleThreadExecutor() } + val scanner = remember { + BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build(), + ) + } + // Guards against multiple fires while the pop-back animation runs. + val delivered = remember { AtomicBoolean(false) } + + DisposableEffect(Unit) { + onDispose { + scanner.close() + analysisExecutor.shutdown() + ProcessCameraProvider.getInstance(context).get().unbindAll() + } + } + + AndroidView( + modifier = modifier, + factory = { viewContext -> + val previewView = PreviewView(viewContext) + val providerFuture = ProcessCameraProvider.getInstance(viewContext) + providerFuture.addListener( + { + val provider = providerFuture.get() + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + analysis.setAnalyzer(analysisExecutor) { imageProxy -> + processFrame(imageProxy, scanner, delivered, onTokenScanned) + } + provider.unbindAll() + provider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis, + ) + }, + ContextCompat.getMainExecutor(viewContext), + ) + previewView + }, + ) + Text( + text = stringResource(R.string.att_qr_hint), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp), + ) +} + +// ImageProxy.getImage() is CameraX-experimental; ML Kit's own docs use it. +@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class) +private fun processFrame( + imageProxy: ImageProxy, + scanner: com.google.mlkit.vision.barcode.BarcodeScanner, + delivered: AtomicBoolean, + onTokenScanned: (String) -> Unit, +) { + val mediaImage = imageProxy.image + if (mediaImage == null || delivered.get()) { + imageProxy.close() + return + } + val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + scanner.process(input) + .addOnSuccessListener { barcodes -> + val token = barcodes.firstOrNull { !it.rawValue.isNullOrBlank() }?.rawValue + if (token != null && delivered.compareAndSet(false, true)) { + onTokenScanned(token) + } + } + .addOnCompleteListener { imageProxy.close() } +} diff --git a/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/selfie/SelfieCaptureScreen.kt b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/selfie/SelfieCaptureScreen.kt new file mode 100644 index 0000000..2de3495 --- /dev/null +++ b/feature/attendance/src/main/kotlin/app/worktrack/feature/attendance/selfie/SelfieCaptureScreen.kt @@ -0,0 +1,254 @@ +package app.worktrack.feature.attendance.selfie + +import android.Manifest +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.util.Base64 +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.NoPhotography +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import app.worktrack.core.designsystem.component.EmptyState +import app.worktrack.core.designsystem.component.WtPrimaryButton +import app.worktrack.core.designsystem.component.WtTopBar +import app.worktrack.feature.attendance.R +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.face.FaceDetection +import com.google.mlkit.vision.face.FaceDetectorOptions +import java.io.ByteArrayOutputStream +import java.util.concurrent.Executors + +/** + * Front-camera selfie capture for photo-verified check-in. A face must be + * detected on-device (ML Kit) before the shutter enables; the captured frame is + * downscaled and returned as a small base64 JPEG data URL via [onCaptured]. + * This is capture-for-review, not 1:1 recognition — no biometric data is stored. + */ +@Composable +fun SelfieCaptureRoute( + onBack: () -> Unit, + onCaptured: (String) -> Unit, +) { + val context = LocalContext.current + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED, + ) + } + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> hasCameraPermission = granted } + + LaunchedEffect(Unit) { + if (!hasCameraPermission) permissionLauncher.launch(Manifest.permission.CAMERA) + } + + Scaffold( + topBar = { WtTopBar(title = stringResource(R.string.att_selfie_title), onBack = onBack) }, + ) { padding -> + if (hasCameraPermission) { + SelfieCamera( + onCaptured = onCaptured, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) + } else { + EmptyState( + icon = Icons.Filled.NoPhotography, + title = stringResource(R.string.att_qr_camera_permission_title), + message = stringResource(R.string.att_qr_camera_permission_msg), + modifier = Modifier.padding(padding), + ) + } + } +} + +@Composable +private fun SelfieCamera( + onCaptured: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val analysisExecutor = remember { Executors.newSingleThreadExecutor() } + val detector = remember { + FaceDetection.getClient( + FaceDetectorOptions.Builder() + .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST) + .build(), + ) + } + val imageCapture = remember { ImageCapture.Builder().build() } + var faceReady by remember { mutableStateOf(false) } + var capturing by remember { mutableStateOf(false) } + + DisposableEffect(Unit) { + onDispose { + detector.close() + analysisExecutor.shutdown() + ProcessCameraProvider.getInstance(context).get().unbindAll() + } + } + + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.weight(1f).fillMaxWidth()) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { viewContext -> + val previewView = PreviewView(viewContext) + val providerFuture = ProcessCameraProvider.getInstance(viewContext) + providerFuture.addListener( + { + val provider = providerFuture.get() + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + analysis.setAnalyzer(analysisExecutor) { proxy -> + detectFace(proxy, detector) { present -> faceReady = present } + } + provider.unbindAll() + provider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_FRONT_CAMERA, + preview, + analysis, + imageCapture, + ) + }, + ContextCompat.getMainExecutor(viewContext), + ) + previewView + }, + ) + } + + Column( + Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource( + if (faceReady) R.string.att_selfie_ready else R.string.att_selfie_hint, + ), + style = MaterialTheme.typography.bodyMedium, + color = if (faceReady) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + WtPrimaryButton( + text = stringResource(R.string.att_selfie_capture), + onClick = { + capturing = true + imageCapture.takePicture( + ContextCompat.getMainExecutor(context), + object : ImageCapture.OnImageCapturedCallback() { + override fun onCaptureSuccess(image: ImageProxy) { + val data = image.toBase64Jpeg() + image.close() + onCaptured(data) + } + + override fun onError(exception: ImageCaptureException) { + capturing = false + } + }, + ) + }, + modifier = Modifier.fillMaxWidth(), + enabled = faceReady && !capturing, + loading = capturing, + ) + } + } +} + +// ImageProxy.getImage() is CameraX-experimental; ML Kit's own docs use it. +@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class) +private fun detectFace( + proxy: ImageProxy, + detector: com.google.mlkit.vision.face.FaceDetector, + onResult: (Boolean) -> Unit, +) { + val mediaImage = proxy.image + if (mediaImage == null) { + proxy.close() + return + } + val input = InputImage.fromMediaImage(mediaImage, proxy.imageInfo.rotationDegrees) + detector.process(input) + .addOnSuccessListener { faces -> onResult(faces.size == 1) } + .addOnCompleteListener { proxy.close() } +} + +/** Captured JPEG proxy -> rotated, downscaled, base64 JPEG data URL (~small). */ +private fun ImageProxy.toBase64Jpeg(): String { + val buffer = planes[0].buffer + val bytes = ByteArray(buffer.remaining()).also { buffer.get(it) } + var bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + + val rotation = imageInfo.rotationDegrees + if (rotation != 0) { + val matrix = Matrix().apply { postRotate(rotation.toFloat()) } + bmp = Bitmap.createBitmap(bmp, 0, 0, bmp.width, bmp.height, matrix, true) + } + + val maxEdge = 320 + val longest = maxOf(bmp.width, bmp.height) + if (longest > maxEdge) { + val scale = maxEdge.toFloat() / longest + bmp = Bitmap.createScaledBitmap( + bmp, + (bmp.width * scale).toInt(), + (bmp.height * scale).toInt(), + true, + ) + } + + val out = ByteArrayOutputStream() + bmp.compress(Bitmap.CompressFormat.JPEG, 60, out) + return "data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) +} diff --git a/feature/attendance/src/main/res/values-en/strings.xml b/feature/attendance/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..1c5d4a2 --- /dev/null +++ b/feature/attendance/src/main/res/values-en/strings.xml @@ -0,0 +1,72 @@ + + + Attendance punch + Location permission is required for GPS punch + Getting your location… + No work area configured — you can punch from anywhere + Inside %1$s + Outside the work area (%1$s m away) + Accuracy ±%1$s m + Scan kiosk QR + Clock in + Clock out + Clocked in — will sync automatically + Clocked out — will sync automatically + GPS punch needs location permission. Use the kiosk QR instead. + Couldn\'t get a GPS fix. Move somewhere more open and retry. + + Scan kiosk QR + Camera permission needed + Allow camera access to scan the kiosk QR code. + Point the camera at the kiosk screen + + Photo verification + Position your face inside the frame + Face detected — you can take the photo + Capture and check in + + Attendance history + No records + Attendance for this month appears here after your first sync. + Previous month + Next month + Worked %1$sh %2$sm + Overtime %1$sm + Late by %1$sm + + Present + Absent + Half day + Leave + Public holiday + Week off + Pending + + Request correction + Attendance correction + Corrected clock-in + Corrected clock-out + Set time + Reason + e.g. Forgot to clock out + Send request + Cancel + Correction filed — applies once your manager approves + Pick at least a clock-in or clock-out time + Enter a reason + Enroll face + Position your face inside the circle + Face detected — tap to enroll + Enroll my face + Saving… + Your face was enrolled successfully + Something went wrong, please try again + The face model is not installed on this device + A face is already enrolled. Ask an administrator to reset it. + Face check-in + Verify & check in + Face verified + This face does not match your enrolled face + No face enrolled yet — enroll your face first + Location is no longer available — check-in was not recorded + diff --git a/feature/attendance/src/main/res/values-ps/strings.xml b/feature/attendance/src/main/res/values-ps/strings.xml new file mode 100644 index 0000000..7a6c80b --- /dev/null +++ b/feature/attendance/src/main/res/values-ps/strings.xml @@ -0,0 +1,72 @@ + + + د حاضرۍ ثبت + د GPS حاضرۍ لپاره د موقعیت اجازه اړینه ده + ستاسو موقعیت ترلاسه کېږي… + کاري ساحه نه ده ټاکل شوې — له هر ځایه حاضري ثبتولی شئ + د %1$s دننه + له کاري ساحې بهر (%1$s متره لرې) + کره‌والی ±%1$s متره + د کیوسک QR سکن کړئ + ورتګ ثبت کړئ + وتل ثبت کړئ + ورتګ ثبت شو — په خپلکاره توګه همغږي کېږي + وتل ثبت شول — په خپلکاره توګه همغږي کېږي + پرته له موقعیت اجازې GPS حاضري نه کېږي. د کیوسک QR وکاروئ. + GPS موقعیت ترلاسه نه شو. خلاصې فضا ته ولاړ شئ او بیا هڅه وکړئ. + + د کیوسک QR سکن + د کامرې اجازه اړینه ده + د کیوسک QR کوډ سکن لپاره کامرې ته اجازه ورکړئ. + کامره د کیوسک پردې ته ونیسئ + + د عکس تایید + خپل مخ د چوکاټ دننه ونیسئ + مخ وپېژندل شو — عکس اخیستلی شئ + عکس واخلئ او حاضري ثبت کړئ + + د حاضرۍ تاریخچه + ریکارډ نشته + د دې میاشتې حاضري د لومړي همغږي کولو وروسته ښکاري. + پخوانۍ میاشت + راتلونکې میاشت + کار %1$s ساعته %2$s دقیقې + اضافه کار %1$s دقیقې + %1$s دقیقې ناوخته + + حاضر + غیرحاضر + نیمه ورځ + رخصتي + عمومي رخصتي + اونیزه رخصتي + په تمه + + د سمون غوښتنه + د حاضرۍ سمون + سم شوی ورتګ + سم شوی وتل + وخت وټاکئ + دلیل + لکه: هېر مې کړل چې وتل ثبت کړم + غوښتنه ولېږئ + لغوه + د سمون غوښتنه ثبت شوه — د مدیر له تایید وروسته پلي کېږي + لږ تر لږه د ورتګ یا وتلو وخت وټاکئ + دلیل ولیکئ + د مخ ثبتول + خپل مخ د دایرې دننه ونیسئ + مخ وپېژندل شو — تڼۍ کېکاږئ + زما مخ ثبت کړه + ثبتېږي… + ستاسو مخ په بریالیتوب سره ثبت شو + ستونزه رامنځته شوه، بیا هڅه وکړئ + د مخ ماډل پدې وسیله نشته + مخ دمخه ثبت شوی دی. له مدیر څخه وغواړئ چې بیا یې تنظیم کړي. + د مخ له لارې ننوتل + تصدیق او ثبت + مخ تصدیق شو + دا مخ ستاسو له ثبت شوي مخ سره سمون نه خوري + لا تر اوسه مخ نه دی ثبت شوی — لومړی خپل مخ ثبت کړئ + موقعیت نور شتون نلري — حاضري ثبت نه شوه + diff --git a/feature/attendance/src/main/res/values/strings.xml b/feature/attendance/src/main/res/values/strings.xml new file mode 100644 index 0000000..02a5eb2 --- /dev/null +++ b/feature/attendance/src/main/res/values/strings.xml @@ -0,0 +1,72 @@ + + + ثبت حاضری + برای حاضری GPS اجازهٔ موقعیت لازم است + در حال دریافت موقعیت شما… + محدودهٔ کاری تعریف نشده — از هر جا می‌توانید حاضری بزنید + داخل %1$s + خارج از ساحهٔ کاری (%1$s متر فاصله) + دقت ±%1$s متر + اسکن QR کیوسک + ثبت ورود + ثبت خروج + ورود ثبت شد — به صورت خودکار همگام می‌شود + خروج ثبت شد — به صورت خودکار همگام می‌شود + بدون اجازهٔ موقعیت، حاضری GPS ممکن نیست. از QR کیوسک استفاده کنید. + موقعیت GPS دریافت نشد. به جای بازتر بروید و دوباره تلاش کنید. + + اسکن QR کیوسک + اجازهٔ کمره لازم است + برای اسکن کود QR کیوسک، اجازهٔ کمره را بدهید. + کمره را به سوی صفحهٔ کیوسک بگیرید + + تأیید با عکس + چهرهٔ خود را داخل کادر قرار دهید + چهره تشخیص شد — می‌توانید عکس بگیرید + گرفتن عکس و ثبت حاضری + + تاریخچهٔ حاضری + ریکاردی نیست + حاضری این ماه بعد از اولین همگام‌سازی نمایش داده می‌شود. + ماه قبلی + ماه بعدی + کارکرد %1$s ساعت %2$s دقیقه + اضافه‌کاری %1$s دقیقه + %1$s دقیقه ناوقت + + حاضر + غیرحاضر + نیم روز + رخصتی + رخصتی عمومی + رخصتی هفته‌وار + در انتظار + + درخواست اصلاح + اصلاح حاضری + ورود اصلاح‌شده + خروج اصلاح‌شده + تنظیم وقت + دلیل + مثلاً: فراموش کردم خروج بزنم + ارسال درخواست + لغو + درخواست اصلاح ثبت شد — بعد از تایید مدیر اعمال می‌شود + حداقل وقت ورود یا خروج را انتخاب کنید + دلیل را بنویسید + ثبت چهره + چهرهٔ خود را داخل دایره قرار دهید + چهره تشخیص شد — دکمه را بزنید + ثبت چهرهٔ من + در حال ثبت… + چهرهٔ شما با موفقیت ثبت شد + خطایی رخ داد، دوباره تلاش کنید + مدل تشخیص چهره روی دستگاه موجود نیست + چهره قبلاً ثبت شده است. از مدیر بخواهید آن را بازنشانی کند. + ورود با چهره + تأیید و ثبت حاضری + چهره تأیید شد + این چهره با چهرهٔ ثبت‌شدهٔ شما مطابقت ندارد + هنوز چهره‌ای ثبت نشده — ابتدا چهرهٔ خود را ثبت کنید + موقعیت مکانی در دسترس نیست — حاضری ثبت نشد + diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts new file mode 100644 index 0000000..234ea24 --- /dev/null +++ b/feature/auth/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.worktrack.android.feature) +} + +android { + namespace = "app.worktrack.feature.auth" +} diff --git a/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginScreen.kt b/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginScreen.kt new file mode 100644 index 0000000..23e9143 --- /dev/null +++ b/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginScreen.kt @@ -0,0 +1,143 @@ +package app.worktrack.feature.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.WtPrimaryButton +import app.worktrack.core.designsystem.component.WtTextField +import app.worktrack.core.designsystem.l10n.localizedMessage + +@Composable +fun LoginRoute(viewModel: LoginViewModel = hiltViewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + LoginScreen( + state = state, + onEmailChange = viewModel::onEmailChange, + onPasswordChange = viewModel::onPasswordChange, + onTogglePasswordVisibility = viewModel::onTogglePasswordVisibility, + onSubmit = viewModel::onSubmit, + ) +} + +@Composable +internal fun LoginScreen( + state: LoginUiState, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onTogglePasswordVisibility: () -> Unit, + onSubmit: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "WorkTrack", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.auth_tagline), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(40.dp)) + + WtTextField( + value = state.email, + onValueChange = onEmailChange, + label = stringResource(R.string.auth_email), + modifier = Modifier.fillMaxWidth(), + errorText = if ("email" in state.fieldErrors) { + stringResource(R.string.auth_email_invalid) + } else { + null + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + ) + Spacer(Modifier.height(16.dp)) + WtTextField( + value = state.password, + onValueChange = onPasswordChange, + label = stringResource(R.string.auth_password), + modifier = Modifier.fillMaxWidth(), + errorText = if ("password" in state.fieldErrors) { + stringResource(R.string.auth_password_short) + } else { + null + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = if (state.passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = onTogglePasswordVisibility) { + Icon( + imageVector = if (state.passwordVisible) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = stringResource( + if (state.passwordVisible) { + R.string.auth_hide_password + } else { + R.string.auth_show_password + }, + ), + ) + } + }, + ) + + state.error?.let { error -> + Spacer(Modifier.height(12.dp)) + Text( + text = error.localizedMessage(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(Modifier.height(24.dp)) + WtPrimaryButton( + text = stringResource(R.string.auth_sign_in), + onClick = onSubmit, + modifier = Modifier.fillMaxWidth(), + loading = state.isSubmitting, + ) + } +} diff --git a/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginViewModel.kt b/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginViewModel.kt new file mode 100644 index 0000000..c7705a8 --- /dev/null +++ b/feature/auth/src/main/kotlin/app/worktrack/feature/auth/LoginViewModel.kt @@ -0,0 +1,70 @@ +package app.worktrack.feature.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.domain.usecase.auth.SignInUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class LoginUiState( + val email: String = "", + val password: String = "", + val passwordVisible: Boolean = false, + val isSubmitting: Boolean = false, + /** Field keys with validation problems; the UI maps keys to localized text. */ + val fieldErrors: Set = emptySet(), + val error: AppError? = null, +) + +/** + * Sign-in flow. Successful sign-in persists the session; the root nav host + * observes the session and switches to the main graph — no nav event needed. + */ +@HiltViewModel +class LoginViewModel @Inject constructor( + private val signIn: SignInUseCase, +) : ViewModel() { + + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onEmailChange(value: String) { + _uiState.update { it.copy(email = value, fieldErrors = it.fieldErrors - "email", error = null) } + } + + fun onPasswordChange(value: String) { + _uiState.update { it.copy(password = value, fieldErrors = it.fieldErrors - "password", error = null) } + } + + fun onTogglePasswordVisibility() { + _uiState.update { it.copy(passwordVisible = !it.passwordVisible) } + } + + fun onSubmit() { + val state = _uiState.value + if (state.isSubmitting) return + _uiState.update { it.copy(isSubmitting = true, error = null, fieldErrors = emptySet()) } + + viewModelScope.launch { + when (val result = signIn(state.email, state.password)) { + is AppResult.Success -> _uiState.update { it.copy(isSubmitting = false) } + is AppResult.Failure -> _uiState.update { + val validation = result.error as? AppError.Validation + it.copy( + isSubmitting = false, + fieldErrors = validation?.fieldErrors?.keys.orEmpty(), + // Field-level problems are surfaced inline, not as a banner. + error = if (validation == null) result.error else null, + ) + } + } + } + } +} diff --git a/feature/auth/src/main/kotlin/app/worktrack/feature/auth/navigation/AuthNavigation.kt b/feature/auth/src/main/kotlin/app/worktrack/feature/auth/navigation/AuthNavigation.kt new file mode 100644 index 0000000..e72af73 --- /dev/null +++ b/feature/auth/src/main/kotlin/app/worktrack/feature/auth/navigation/AuthNavigation.kt @@ -0,0 +1,17 @@ +package app.worktrack.feature.auth.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import app.worktrack.feature.auth.LoginRoute + +const val AUTH_GRAPH_ROUTE = "auth" +const val LOGIN_ROUTE = "auth/login" + +fun NavGraphBuilder.authGraph() { + navigation(startDestination = LOGIN_ROUTE, route = AUTH_GRAPH_ROUTE) { + composable(route = LOGIN_ROUTE) { + LoginRoute() + } + } +} diff --git a/feature/auth/src/main/res/values-en/strings.xml b/feature/auth/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..523c1df --- /dev/null +++ b/feature/auth/src/main/res/values-en/strings.xml @@ -0,0 +1,11 @@ + + + Smart workforce management for Afghanistan + Work email + Password + Sign in + Show password + Hide password + Enter a valid email address + Password must be at least 8 characters + diff --git a/feature/auth/src/main/res/values-ps/strings.xml b/feature/auth/src/main/res/values-ps/strings.xml new file mode 100644 index 0000000..e4eb772 --- /dev/null +++ b/feature/auth/src/main/res/values-ps/strings.xml @@ -0,0 +1,11 @@ + + + د افغانستان لپاره د کاري ځواک هوښیار مدیریت + کاري برېښنالیک + پټنوم + ننوتل + پټنوم ښکاره کړئ + پټنوم پټ کړئ + سم برېښنالیک ولیکئ + پټنوم باید لږ تر لږه ۸ توري وي + diff --git a/feature/auth/src/main/res/values/strings.xml b/feature/auth/src/main/res/values/strings.xml new file mode 100644 index 0000000..71a2f9c --- /dev/null +++ b/feature/auth/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + مدیریت هوشمند نیروی کار برای افغانستان + ایمیل کاری + رمز عبور + ورود + نمایش رمز عبور + پنهان کردن رمز عبور + یک ایمیل معتبر درج کنید + رمز عبور باید حداقل ۸ حرف باشد + diff --git a/feature/dashboard/build.gradle.kts b/feature/dashboard/build.gradle.kts new file mode 100644 index 0000000..6ca9118 --- /dev/null +++ b/feature/dashboard/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.worktrack.android.feature) +} + +android { + namespace = "app.worktrack.feature.dashboard" +} diff --git a/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardScreen.kt b/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardScreen.kt new file mode 100644 index 0000000..9c9b6c5 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardScreen.kt @@ -0,0 +1,427 @@ +package app.worktrack.feature.dashboard + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.ChipTone +import app.worktrack.core.designsystem.component.FullScreenLoading +import app.worktrack.core.designsystem.component.SectionHeader +import app.worktrack.core.designsystem.component.StatusChip +import app.worktrack.core.designsystem.component.WtPrimaryButton +import app.worktrack.core.designsystem.component.WtSecondaryButton +import app.worktrack.core.designsystem.l10n.formatClockTime +import app.worktrack.core.designsystem.l10n.formatShamsiDate +import app.worktrack.core.designsystem.l10n.localizedDigits +import app.worktrack.core.domain.usecase.dashboard.DashboardSnapshot +import app.worktrack.core.model.Announcement +import app.worktrack.core.model.AnnouncementPriority +import app.worktrack.core.model.LeaveBalance +import app.worktrack.core.model.TaskStatus +import app.worktrack.core.model.WorkDay +import app.worktrack.core.model.WorkTask + +@Composable +fun DashboardRoute( + onPunchClick: () -> Unit, + onAttendanceHistoryClick: () -> Unit, + viewModel: DashboardViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val statusError by viewModel.statusError.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + val offlineMessage = stringResource(R.string.dash_work_offline) + + LaunchedEffect(statusError) { + if (statusError) { + snackbar.showSnackbar(offlineMessage) + viewModel.onStatusErrorShown() + } + } + + Box(Modifier.fillMaxSize()) { + when (val s = state) { + DashboardUiState.Loading -> FullScreenLoading() + is DashboardUiState.Ready -> DashboardScreen( + snapshot = s.snapshot, + onPunchClick = onPunchClick, + onAttendanceHistoryClick = onAttendanceHistoryClick, + onTaskStatus = viewModel::onTaskStatus, + ) + } + SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter)) + } +} + +@Composable +internal fun DashboardScreen( + snapshot: DashboardSnapshot, + onPunchClick: () -> Unit, + onAttendanceHistoryClick: () -> Unit, + onTaskStatus: (String, TaskStatus) -> Unit = { _, _ -> }, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = stringResource( + R.string.dash_greeting, + snapshot.session.displayName.substringBefore(' '), + ), + style = MaterialTheme.typography.headlineSmall, + ) + Text( + text = snapshot.session.companyName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + item { + TodayCard( + snapshot = snapshot, + onPunchClick = onPunchClick, + onAttendanceHistoryClick = onAttendanceHistoryClick, + ) + } + + // Above leave and announcements on purpose: this is what the worker + // opened the app to find out, and it is useless once he has walked past + // the wrong part of the site. + item { SectionHeader(stringResource(R.string.dash_work_today)) } + item { WorkDayCard(snapshot.myWork.today, isToday = true, onTaskStatus = onTaskStatus) } + + snapshot.myWork.next?.let { next -> + item { + SectionHeader( + stringResource(R.string.dash_work_next, formatShamsiDate(next.date, withWeekday = true)), + ) + } + item { WorkDayCard(next, isToday = false, onTaskStatus = onTaskStatus) } + } + + if (snapshot.leaveBalances.isNotEmpty()) { + item { SectionHeader(stringResource(R.string.dash_leave_balances)) } + item { BalancesRow(snapshot.leaveBalances) } + } + + if (snapshot.announcements.isNotEmpty()) { + item { SectionHeader(stringResource(R.string.dash_announcements)) } + items(snapshot.announcements, key = { it.id }) { announcement -> + AnnouncementCard(announcement) + } + } + + item { Spacer(Modifier.height(24.dp)) } + } +} + +@Composable +private fun TodayCard( + snapshot: DashboardSnapshot, + onPunchClick: () -> Unit, + onAttendanceHistoryClick: () -> Unit, +) { + val today = snapshot.today + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text( + text = stringResource( + if (today.clockedIn) R.string.dash_clocked_in else R.string.dash_not_clocked_in, + ), + style = MaterialTheme.typography.titleMedium, + ) + today.firstInAt?.let { + Text( + text = stringResource(R.string.dash_first_in, formatClockTime(it)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = localizedDigits( + stringResource( + R.string.dash_worked, + (today.workedMinutesSoFar / 60).toString(), + (today.workedMinutesSoFar % 60).toString(), + ), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + StatusChip( + text = stringResource( + if (today.clockedIn) R.string.dash_chip_in else R.string.dash_chip_out, + ), + tone = if (today.clockedIn) ChipTone.POSITIVE else ChipTone.NEUTRAL, + ) + } + + today.shift?.let { shift -> + Spacer(Modifier.height(8.dp)) + Text( + text = localizedDigits( + stringResource( + R.string.dash_shift, + shift.name, + shift.startTime.toString(), + shift.endTime.toString(), + ), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(12.dp)) + Row { + WtPrimaryButton( + text = stringResource( + if (today.clockedIn) R.string.dash_clock_out else R.string.dash_clock_in, + ), + onClick = onPunchClick, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(12.dp)) + WtSecondaryButton( + text = stringResource(R.string.dash_history), + onClick = onAttendanceHistoryClick, + ) + } + } + } +} + +@Composable +private fun BalancesRow(balances: List) { + LazyRow( + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(balances, key = { it.id }) { balance -> + Card { + Column(Modifier.padding(12.dp)) { + Text( + text = localizedDigits("%.1f".format(balance.availableDays)), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.dash_days_available), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun AnnouncementCard(announcement: Announcement) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = announcement.title, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + if (announcement.priority != AnnouncementPriority.NORMAL) { + StatusChip( + text = stringResource( + if (announcement.priority == AnnouncementPriority.URGENT) { + R.string.dash_priority_urgent + } else { + R.string.dash_priority_important + }, + ), + tone = if (announcement.priority == AnnouncementPriority.URGENT) { + ChipTone.NEGATIVE + } else { + ChipTone.WARNING + }, + ) + } + } + Spacer(Modifier.height(4.dp)) + Text( + text = announcement.body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * One day of assigned work. + * + * An empty day says so in words. A blank card would be read as "the app is + * broken" or, worse, as "nothing to do" — and the two are not the same thing. + */ +@Composable +private fun WorkDayCard( + day: WorkDay, + isToday: Boolean, + onTaskStatus: (String, TaskStatus) -> Unit, +) { + if (day.tasks.isEmpty()) { + Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + Text( + text = stringResource( + if (isToday) R.string.dash_work_none_today else R.string.dash_work_none_next, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + return + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + day.tasks.forEach { task -> + TaskCard(task = task, actionable = isToday, onTaskStatus = onTaskStatus) + } + } +} + +@Composable +private fun TaskCard( + task: WorkTask, + actionable: Boolean, + onTaskStatus: (String, TaskStatus) -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { + Text(text = task.title, style = MaterialTheme.typography.titleMedium) + // The project and the place: which part of the job, and where. + Text( + text = listOfNotNull(task.projectName, task.location).joinToString(" — "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + StatusChip(text = statusLabel(task.status), tone = statusTone(task.status)) + } + + task.detail?.let { + Spacer(Modifier.height(6.dp)) + Text(text = it, style = MaterialTheme.typography.bodyMedium) + } + + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + StatusChip( + text = stringResource( + if (task.isTeamWork) R.string.dash_work_team else R.string.dash_work_solo, + ), + tone = ChipTone.NEUTRAL, + ) + task.teamName?.let { + Spacer(Modifier.width(8.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (task.isTeamWork) { + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource( + R.string.dash_work_with, + task.assigneeNames.joinToString("، "), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Only today's work can be reported on: marking tomorrow's job + // finished today is never something the worker meant to do. + if (actionable && task.status != TaskStatus.DONE) { + Spacer(Modifier.height(12.dp)) + Row { + if (task.status != TaskStatus.IN_PROGRESS) { + WtSecondaryButton( + text = stringResource(R.string.dash_work_start), + onClick = { onTaskStatus(task.id, TaskStatus.IN_PROGRESS) }, + ) + Spacer(Modifier.width(12.dp)) + } + WtPrimaryButton( + text = stringResource(R.string.dash_work_finish), + onClick = { onTaskStatus(task.id, TaskStatus.DONE) }, + modifier = Modifier.weight(1f), + ) + } + } + } + } +} + +@Composable +private fun statusLabel(status: TaskStatus): String = stringResource( + when (status) { + TaskStatus.PLANNED -> R.string.dash_work_status_planned + TaskStatus.IN_PROGRESS -> R.string.dash_work_status_in_progress + TaskStatus.DONE -> R.string.dash_work_status_done + TaskStatus.BLOCKED -> R.string.dash_work_status_blocked + }, +) + +private fun statusTone(status: TaskStatus): ChipTone = when (status) { + TaskStatus.PLANNED -> ChipTone.NEUTRAL + TaskStatus.IN_PROGRESS -> ChipTone.WARNING + TaskStatus.DONE -> ChipTone.POSITIVE + TaskStatus.BLOCKED -> ChipTone.NEGATIVE +} diff --git a/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardViewModel.kt b/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..7638bf8 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/DashboardViewModel.kt @@ -0,0 +1,65 @@ +package app.worktrack.feature.dashboard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.domain.usecase.dashboard.DashboardSnapshot +import app.worktrack.core.domain.usecase.dashboard.ObserveDashboardUseCase +import app.worktrack.core.domain.usecase.sync.TriggerSyncUseCase +import app.worktrack.core.domain.usecase.work.SetTaskStatusUseCase +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.model.TaskStatus +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +sealed interface DashboardUiState { + data object Loading : DashboardUiState + data class Ready(val snapshot: DashboardSnapshot) : DashboardUiState +} + +@HiltViewModel +class DashboardViewModel @Inject constructor( + observeDashboard: ObserveDashboardUseCase, + private val triggerSync: TriggerSyncUseCase, + private val setTaskStatus: SetTaskStatusUseCase, +) : ViewModel() { + + /** + * Set when reporting progress could not reach the server. + * + * Reporting is online-only: the outbox carries creations, and quietly + * queuing a status change would show the worker a green tick for something + * the foreman never saw. Better to say the message did not get through. + */ + private val _statusError = MutableStateFlow(false) + val statusError: StateFlow = _statusError.asStateFlow() + + fun onTaskStatus(taskId: String, status: TaskStatus) { + viewModelScope.launch { + _statusError.value = setTaskStatus(taskId, status) is AppResult.Failure + } + } + + fun onStatusErrorShown() { + _statusError.value = false + } + + val uiState: StateFlow = observeDashboard() + .map { snapshot -> + if (snapshot == null) DashboardUiState.Loading else DashboardUiState.Ready(snapshot) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = DashboardUiState.Loading, + ) + + /** Pull-to-refresh: sync runs in the background; Room flows update the UI. */ + fun onRefresh() = triggerSync() +} diff --git a/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/navigation/DashboardNavigation.kt b/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/navigation/DashboardNavigation.kt new file mode 100644 index 0000000..3c27d67 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/app/worktrack/feature/dashboard/navigation/DashboardNavigation.kt @@ -0,0 +1,19 @@ +package app.worktrack.feature.dashboard.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import app.worktrack.feature.dashboard.DashboardRoute + +const val DASHBOARD_ROUTE = "dashboard" + +fun NavGraphBuilder.dashboardScreen( + onPunchClick: () -> Unit, + onAttendanceHistoryClick: () -> Unit, +) { + composable(route = DASHBOARD_ROUTE) { + DashboardRoute( + onPunchClick = onPunchClick, + onAttendanceHistoryClick = onAttendanceHistoryClick, + ) + } +} diff --git a/feature/dashboard/src/main/res/values-en/strings.xml b/feature/dashboard/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..be6da24 --- /dev/null +++ b/feature/dashboard/src/main/res/values-en/strings.xml @@ -0,0 +1,33 @@ + + + Hello, %1$s + Clocked in + Not clocked in yet + First in %1$s + Worked %1$sh %2$sm + Shift: %1$s (%2$s–%3$s) + Clock in + Clock out + History + Leave balances + days available + Announcements + IN + OUT + Important + Urgent + Your work today + Work for %1$s + Nothing assigned to you today. Ask your supervisor if that seems wrong. + Nothing assigned for that day yet. + Team job + Individual + With: %1$s + Started + Finished + Planned + In progress + Done + Blocked + You need a connection to record that. Your work is still here. + diff --git a/feature/dashboard/src/main/res/values-ps/strings.xml b/feature/dashboard/src/main/res/values-ps/strings.xml new file mode 100644 index 0000000..33fe527 --- /dev/null +++ b/feature/dashboard/src/main/res/values-ps/strings.xml @@ -0,0 +1,33 @@ + + + سلام، %1$s + د ورتګ حاضري ثبت شوې + تر اوسه حاضري نه ده ثبت شوې + لومړی ورتګ %1$s + کار %1$s ساعته او %2$s دقیقې + شفټ: %1$s (%2$s تر %3$s) + ورتګ ثبت کړئ + وتل ثبت کړئ + تاریخچه + د رخصتۍ بیلانس + پاتې ورځې + اعلانونه + حاضر + بهر + مهم + بېړنی + ستاسو د نن کار + د %1$s کار + د نن ورځې لپاره تاسو ته کار نه دی ټاکل شوی. که ډاډه نه یاست، له خپل سرپرست وپوښتئ. + د دې ورځې لپاره لا کار نه دی ټاکل شوی. + ټیمي کار + انفرادي + ملګري: %1$s + پیل مې کړ + بشپړ شو + پلان شوی + روان + ترسره شو + درېدلی + د دې بدلون ثبتولو لپاره انټرنټ ته اړتیا ده. ستاسو کار همدلته خوندي دی. + diff --git a/feature/dashboard/src/main/res/values/strings.xml b/feature/dashboard/src/main/res/values/strings.xml new file mode 100644 index 0000000..79d786d --- /dev/null +++ b/feature/dashboard/src/main/res/values/strings.xml @@ -0,0 +1,33 @@ + + + سلام، %1$s + حاضری ورود ثبت شده + هنوز حاضری نزده‌اید + اولین ورود %1$s + کارکرد %1$s ساعت و %2$s دقیقه + شفت: %1$s (%2$s تا %3$s) + ثبت ورود + ثبت خروج + تاریخچه + بیلانس رخصتی + روز باقی‌مانده + اعلانات + حاضر + خارج + مهم + عاجل + کار امروز شما + کار %1$s + برای امروز کاری به شما تعیین نشده. اگر مطمئن نیستید، از سرپرست خود بپرسید. + برای این روز هنوز کاری تعیین نشده. + کار تیمی + انفرادی + همراه: %1$s + شروع کردم + تمام شد + پلان‌شده + در جریان + انجام شد + متوقف + برای ثبت این تغییر به انترنت نیاز است. کار شما همینجا محفوظ است. + diff --git a/feature/leave/build.gradle.kts b/feature/leave/build.gradle.kts new file mode 100644 index 0000000..0e04d66 --- /dev/null +++ b/feature/leave/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.worktrack.android.feature) +} + +android { + namespace = "app.worktrack.feature.leave" +} diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/LeaveTypeNames.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/LeaveTypeNames.kt new file mode 100644 index 0000000..b3fbe7e --- /dev/null +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/LeaveTypeNames.kt @@ -0,0 +1,15 @@ +package app.worktrack.feature.leave + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import app.worktrack.core.model.LeaveType + +/** Only the untouched Dari names signup seeds are translated; a company's own name stays as typed. */ +internal fun builtInNameRes(type: LeaveType): Int? = when { + type.code == "ANNUAL" && type.name == "رخصتی سالانه" -> R.string.leave_type_annual + type.code == "SICK" && type.name == "رخصتی مریضی" -> R.string.leave_type_sick + else -> null +} + +@Composable +internal fun LeaveType.displayName(): String = builtInNameRes(this)?.let { stringResource(it) } ?: name diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveScreen.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveScreen.kt new file mode 100644 index 0000000..d1ebd2d --- /dev/null +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveScreen.kt @@ -0,0 +1,256 @@ +package app.worktrack.feature.leave.apply + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AssistChip +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.SectionHeader +import app.worktrack.core.designsystem.component.WtPrimaryButton +import app.worktrack.core.designsystem.component.WtTextField +import app.worktrack.core.designsystem.component.WtTopBar +import app.worktrack.core.designsystem.l10n.formatShamsiDate +import app.worktrack.core.designsystem.l10n.localizedDigits +import app.worktrack.core.designsystem.l10n.localizedMessage +import app.worktrack.feature.leave.R +import app.worktrack.feature.leave.displayName +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset + +@Composable +fun ApplyLeaveRoute( + onBack: () -> Unit, + viewModel: ApplyLeaveViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val types by viewModel.types.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.effects.collect { effect -> + when (effect) { + ApplyLeaveEffect.Submitted -> onBack() + is ApplyLeaveEffect.Failed -> + snackbarHostState.showSnackbar(effect.error.localizedMessage(context)) + } + } + } + + Scaffold( + topBar = { WtTopBar(title = stringResource(R.string.leave_apply_title), onBack = onBack) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + ApplyLeaveScreen( + state = state, + typeNames = types.associate { it.id to it.displayName() }, + onTypeSelect = viewModel::onTypeSelect, + onStartDate = viewModel::onStartDate, + onEndDate = viewModel::onEndDate, + onStartHalfDayToggle = viewModel::onStartHalfDayToggle, + onEndHalfDayToggle = viewModel::onEndHalfDayToggle, + onReasonChange = viewModel::onReasonChange, + onSubmit = viewModel::onSubmit, + modifier = Modifier.padding(padding), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ApplyLeaveScreen( + state: ApplyLeaveUiState, + typeNames: Map, + onTypeSelect: (String) -> Unit, + onStartDate: (LocalDate) -> Unit, + onEndDate: (LocalDate) -> Unit, + onStartHalfDayToggle: () -> Unit, + onEndHalfDayToggle: () -> Unit, + onReasonChange: (String) -> Unit, + onSubmit: () -> Unit, + modifier: Modifier = Modifier, +) { + var datePickerTarget by remember { mutableStateOf(null) } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 32.dp), + ) { + SectionHeader(stringResource(R.string.leave_type_section)) + Row( + Modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + typeNames.forEach { (id, name) -> + FilterChip( + selected = state.leaveTypeId == id, + onClick = { onTypeSelect(id) }, + label = { Text(name) }, + ) + } + } + if ("leaveTypeId" in state.fieldErrors) FieldError(stringResource(R.string.leave_err_type)) + + SectionHeader(stringResource(R.string.leave_dates_section)) + Row( + Modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AssistChip( + onClick = { datePickerTarget = DateTarget.START }, + label = { + Text( + state.startDate + ?.let { formatShamsiDate(it, withYear = true) } + ?: stringResource(R.string.leave_start_date), + ) + }, + ) + AssistChip( + onClick = { datePickerTarget = DateTarget.END }, + label = { + Text( + state.endDate + ?.let { formatShamsiDate(it, withYear = true) } + ?: stringResource(R.string.leave_end_date), + ) + }, + ) + } + if ("startDate" in state.fieldErrors) FieldError(stringResource(R.string.leave_err_start)) + if ("endDate" in state.fieldErrors) FieldError(stringResource(R.string.leave_err_end)) + + Row( + Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = state.startHalfDay, + onClick = onStartHalfDayToggle, + label = { Text(stringResource(R.string.leave_half_first)) }, + ) + FilterChip( + selected = state.endHalfDay, + onClick = onEndHalfDayToggle, + label = { Text(stringResource(R.string.leave_half_last)) }, + ) + } + + if (state.estimatedDays > 0) { + Text( + text = localizedDigits( + stringResource(R.string.leave_estimate, "%.1f".format(state.estimatedDays)), + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + + SectionHeader(stringResource(R.string.leave_reason_section)) + WtTextField( + value = state.reason, + onValueChange = onReasonChange, + label = stringResource(R.string.leave_reason_label), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + errorText = if ("reason" in state.fieldErrors) { + stringResource(R.string.leave_err_reason) + } else { + null + }, + singleLine = false, + ) + + Spacer(Modifier.height(24.dp)) + WtPrimaryButton( + text = stringResource(R.string.leave_submit), + onClick = onSubmit, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + loading = state.isSubmitting, + ) + } + + datePickerTarget?.let { target -> + val initial = when (target) { + DateTarget.START -> state.startDate + DateTarget.END -> state.endDate + } ?: LocalDate.now() + val pickerState = rememberDatePickerState( + initialSelectedDateMillis = initial.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(), + ) + DatePickerDialog( + onDismissRequest = { datePickerTarget = null }, + confirmButton = { + TextButton( + onClick = { + pickerState.selectedDateMillis?.let { millis -> + val date = Instant.ofEpochMilli(millis) + .atZone(ZoneOffset.UTC) + .toLocalDate() + when (target) { + DateTarget.START -> onStartDate(date) + DateTarget.END -> onEndDate(date) + } + } + datePickerTarget = null + }, + ) { Text(stringResource(app.worktrack.core.designsystem.R.string.ds_ok)) } + }, + dismissButton = { + TextButton(onClick = { datePickerTarget = null }) { + Text(stringResource(app.worktrack.core.designsystem.R.string.ds_cancel)) + } + }, + ) { + DatePicker(state = pickerState) + } + } +} + +private enum class DateTarget { START, END } + +@Composable +private fun FieldError(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveViewModel.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveViewModel.kt new file mode 100644 index 0000000..d9afb2c --- /dev/null +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/apply/ApplyLeaveViewModel.kt @@ -0,0 +1,134 @@ +package app.worktrack.feature.leave.apply + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.domain.repository.LeaveRepository +import app.worktrack.core.domain.usecase.leave.ApplyLeaveUseCase +import app.worktrack.core.model.LeaveApplication +import app.worktrack.core.model.LeaveType +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.LocalDate +import javax.inject.Inject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class ApplyLeaveUiState( + val leaveTypeId: String? = null, + val startDate: LocalDate? = null, + val endDate: LocalDate? = null, + val startHalfDay: Boolean = false, + val endHalfDay: Boolean = false, + val reason: String = "", + val isSubmitting: Boolean = false, + /** Field keys with problems; the UI maps keys to localized messages. */ + val fieldErrors: Set = emptySet(), +) { + val estimatedDays: Double + get() { + val start = startDate ?: return 0.0 + val end = endDate ?: return 0.0 + if (leaveTypeId == null) return 0.0 + return ApplyLeaveUseCase.calculateDays( + LeaveApplication(leaveTypeId, start, end, startHalfDay, endHalfDay, reason), + ) + } +} + +sealed interface ApplyLeaveEffect { + data object Submitted : ApplyLeaveEffect + data class Failed(val error: AppError) : ApplyLeaveEffect +} + +@HiltViewModel +class ApplyLeaveViewModel @Inject constructor( + leaveRepository: LeaveRepository, + private val applyLeave: ApplyLeaveUseCase, +) : ViewModel() { + + val types: StateFlow> = leaveRepository.observeTypes() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + private val _uiState = MutableStateFlow(ApplyLeaveUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _effects = Channel(Channel.BUFFERED) + val effects = _effects.receiveAsFlow() + + fun onTypeSelect(typeId: String) = _uiState.update { it.copy(leaveTypeId = typeId) } + + fun onStartDate(date: LocalDate) = _uiState.update { + it.copy( + startDate = date, + // Keep the range valid as the user picks dates out of order. + endDate = it.endDate?.takeIf { end -> !end.isBefore(date) } ?: date, + fieldErrors = it.fieldErrors - "startDate", + ) + } + + fun onEndDate(date: LocalDate) = _uiState.update { + it.copy(endDate = date, fieldErrors = it.fieldErrors - "endDate") + } + + fun onStartHalfDayToggle() = _uiState.update { it.copy(startHalfDay = !it.startHalfDay) } + + fun onEndHalfDayToggle() = _uiState.update { it.copy(endHalfDay = !it.endHalfDay) } + + fun onReasonChange(value: String) = _uiState.update { + it.copy(reason = value, fieldErrors = it.fieldErrors - "reason") + } + + fun onSubmit() { + val state = _uiState.value + if (state.isSubmitting) return + + val typeId = state.leaveTypeId + val start = state.startDate + val end = state.endDate + val missing = buildSet { + if (typeId == null) add("leaveTypeId") + if (start == null) add("startDate") + if (end == null) add("endDate") + if (state.reason.isBlank()) add("reason") + } + if (missing.isNotEmpty() || typeId == null || start == null || end == null) { + _uiState.update { it.copy(fieldErrors = missing) } + return + } + + _uiState.update { it.copy(isSubmitting = true, fieldErrors = emptySet()) } + viewModelScope.launch { + val result = applyLeave( + LeaveApplication( + leaveTypeId = typeId, + startDate = start, + endDate = end, + startHalfDay = state.startHalfDay, + endHalfDay = state.endHalfDay, + reason = state.reason, + ), + ) + when (result) { + is AppResult.Success -> _effects.send(ApplyLeaveEffect.Submitted) + is AppResult.Failure -> { + _uiState.update { + it.copy( + fieldErrors = (result.error as? AppError.Validation) + ?.fieldErrors?.keys.orEmpty(), + ) + } + _effects.send(ApplyLeaveEffect.Failed(result.error)) + } + } + _uiState.update { it.copy(isSubmitting = false) } + } + } +} diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsScreen.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsScreen.kt new file mode 100644 index 0000000..75cf69e --- /dev/null +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsScreen.kt @@ -0,0 +1,206 @@ +package app.worktrack.feature.leave.approvals + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Inbox +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.EmptyState +import app.worktrack.core.designsystem.component.WtPrimaryButton +import app.worktrack.core.designsystem.component.WtSecondaryButton +import app.worktrack.core.designsystem.component.WtTextField +import app.worktrack.core.designsystem.component.WtTopBar +import app.worktrack.core.designsystem.l10n.formatShamsiRange +import app.worktrack.core.designsystem.l10n.localizedDigits +import app.worktrack.core.designsystem.l10n.localizedMessage +import app.worktrack.core.model.ApprovalDecision +import app.worktrack.core.model.LeaveRequest +import app.worktrack.feature.leave.R + +@Composable +fun ApprovalsRoute( + onBack: () -> Unit, + viewModel: ApprovalsViewModel = hiltViewModel(), +) { + val pending by viewModel.pending.collectAsStateWithLifecycle() + val deciding by viewModel.deciding.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + var rejectTarget by remember { mutableStateOf(null) } + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.effects.collect { effect -> + when (effect) { + is ApprovalsEffect.Decided -> snackbarHostState.showSnackbar( + context.getString( + if (effect.decision == ApprovalDecision.APPROVE) { + R.string.leave_msg_approved + } else { + R.string.leave_msg_rejected + }, + ), + ) + + is ApprovalsEffect.Failed -> + snackbarHostState.showSnackbar(effect.error.localizedMessage(context)) + } + } + } + + Scaffold( + topBar = { WtTopBar(title = stringResource(R.string.leave_approvals_title), onBack = onBack) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + if (pending.isEmpty()) { + EmptyState( + icon = Icons.Filled.Inbox, + title = stringResource(R.string.leave_approvals_empty_title), + message = stringResource(R.string.leave_approvals_empty_msg), + modifier = Modifier.padding(padding), + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(pending, key = { it.id }) { request -> + ApprovalCard( + request = request, + busy = request.id in deciding, + onApprove = { + viewModel.onDecide(request.id, ApprovalDecision.APPROVE, note = null) + }, + onReject = { rejectTarget = request }, + ) + } + } + } + } + + rejectTarget?.let { target -> + RejectDialog( + employeeName = target.employeeName ?: target.employeeId, + onConfirm = { note -> + viewModel.onDecide(target.id, ApprovalDecision.REJECT, note) + rejectTarget = null + }, + onDismiss = { rejectTarget = null }, + ) + } +} + +@Composable +private fun ApprovalCard( + request: LeaveRequest, + busy: Boolean, + onApprove: () -> Unit, + onReject: () -> Unit, +) { + Card( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Column(Modifier.padding(12.dp)) { + Text( + text = request.employeeName ?: request.employeeId, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = formatShamsiRange(request.startDate, request.endDate) + + " · " + + localizedDigits( + stringResource(R.string.leave_days_count, "%.1f".format(request.days)), + ), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = request.reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Row { + WtPrimaryButton( + text = stringResource(R.string.leave_approve), + onClick = onApprove, + modifier = Modifier.weight(1f), + loading = busy, + ) + Spacer(Modifier.width(8.dp)) + WtSecondaryButton( + text = stringResource(R.string.leave_reject), + onClick = onReject, + modifier = Modifier.weight(1f), + enabled = !busy, + ) + } + } + } +} + +@Composable +private fun RejectDialog( + employeeName: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var note by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.leave_reject_dialog_title)) }, + text = { + Column { + Text(stringResource(R.string.leave_reject_dialog_msg, employeeName)) + Spacer(Modifier.height(8.dp)) + WtTextField( + value = note, + onValueChange = { note = it }, + label = stringResource(R.string.leave_reject_reason), + singleLine = false, + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(note) }, + enabled = note.isNotBlank(), + ) { Text(stringResource(R.string.leave_reject)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(app.worktrack.core.designsystem.R.string.ds_cancel)) + } + }, + ) +} diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsViewModel.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsViewModel.kt new file mode 100644 index 0000000..638f8f9 --- /dev/null +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/approvals/ApprovalsViewModel.kt @@ -0,0 +1,55 @@ +package app.worktrack.feature.leave.approvals + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.domain.usecase.leave.DecideLeaveRequestUseCase +import app.worktrack.core.domain.usecase.leave.ObservePendingApprovalsUseCase +import app.worktrack.core.model.ApprovalDecision +import app.worktrack.core.model.LeaveRequest +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +sealed interface ApprovalsEffect { + data class Decided(val decision: ApprovalDecision) : ApprovalsEffect + data class Failed(val error: AppError) : ApprovalsEffect +} + +@HiltViewModel +class ApprovalsViewModel @Inject constructor( + observePendingApprovals: ObservePendingApprovalsUseCase, + private val decideRequest: DecideLeaveRequestUseCase, +) : ViewModel() { + + val pending: StateFlow> = observePendingApprovals() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** Request ids with an in-flight decision, to disable their buttons. */ + private val _deciding = MutableStateFlow>(emptySet()) + val deciding: StateFlow> = _deciding.asStateFlow() + + private val _effects = Channel(Channel.BUFFERED) + val effects = _effects.receiveAsFlow() + + fun onDecide(requestId: String, decision: ApprovalDecision, note: String?) { + if (requestId in _deciding.value) return + _deciding.update { it + requestId } + viewModelScope.launch { + when (val result = decideRequest(requestId, decision, note)) { + is AppResult.Success -> _effects.send(ApprovalsEffect.Decided(decision)) + is AppResult.Failure -> _effects.send(ApprovalsEffect.Failed(result.error)) + } + _deciding.update { it - requestId } + } + } +} diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/navigation/LeaveNavigation.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/navigation/LeaveNavigation.kt new file mode 100644 index 0000000..7235dee --- /dev/null +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/navigation/LeaveNavigation.kt @@ -0,0 +1,33 @@ +package app.worktrack.feature.leave.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navDeepLink +import app.worktrack.feature.leave.apply.ApplyLeaveRoute +import app.worktrack.feature.leave.approvals.ApprovalsRoute +import app.worktrack.feature.leave.overview.LeaveOverviewRoute + +const val LEAVE_ROUTE = "leave" +const val APPLY_LEAVE_ROUTE = "leave/apply" +const val APPROVALS_ROUTE = "leave/approvals" + +fun NavGraphBuilder.leaveScreens(navController: NavController) { + composable(route = LEAVE_ROUTE) { + LeaveOverviewRoute( + onApplyClick = { navController.navigate(APPLY_LEAVE_ROUTE) }, + onApprovalsClick = { navController.navigate(APPROVALS_ROUTE) }, + ) + } + + composable(route = APPLY_LEAVE_ROUTE) { + ApplyLeaveRoute(onBack = { navController.popBackStack() }) + } + + composable( + route = APPROVALS_ROUTE, + deepLinks = listOf(navDeepLink { uriPattern = "worktrack://approvals" }), + ) { + ApprovalsRoute(onBack = { navController.popBackStack() }) + } +} diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewScreen.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewScreen.kt new file mode 100644 index 0000000..28b70f7 --- /dev/null +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewScreen.kt @@ -0,0 +1,294 @@ +package app.worktrack.feature.leave.overview + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.BeachAccess +import androidx.compose.material3.Card +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.ChipTone +import app.worktrack.core.designsystem.component.ColorDotChip +import app.worktrack.core.designsystem.component.EmptyState +import app.worktrack.core.designsystem.component.SectionHeader +import app.worktrack.core.designsystem.component.StatusChip +import app.worktrack.core.designsystem.l10n.formatShamsiRange +import app.worktrack.core.designsystem.l10n.localizedDigits +import app.worktrack.core.designsystem.l10n.localizedMessage +import app.worktrack.core.model.LeaveBalance +import app.worktrack.core.model.LeaveRequest +import app.worktrack.core.model.LeaveStatus +import app.worktrack.core.model.LeaveType +import app.worktrack.core.model.SyncStatus +import app.worktrack.feature.leave.R +import app.worktrack.feature.leave.displayName + +@Composable +fun LeaveOverviewRoute( + onApplyClick: () -> Unit, + onApprovalsClick: () -> Unit, + viewModel: LeaveOverviewViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.effects.collect { effect -> + when (effect) { + LeaveOverviewEffect.Cancelled -> + snackbarHostState.showSnackbar(context.getString(R.string.leave_msg_cancelled)) + + is LeaveOverviewEffect.Failed -> + snackbarHostState.showSnackbar(effect.error.localizedMessage(context)) + } + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = onApplyClick, + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + text = { Text(stringResource(R.string.leave_apply)) }, + ) + }, + ) { padding -> + LeaveOverviewScreen( + state = state, + onApprovalsClick = onApprovalsClick, + onCancelRequest = viewModel::onCancelRequest, + modifier = Modifier.padding(padding), + ) + } +} + +@Composable +internal fun LeaveOverviewScreen( + state: LeaveOverviewUiState, + onApprovalsClick: () -> Unit, + onCancelRequest: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (state.isApprover) { + item { + Card( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.leave_pending_team), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onApprovalsClick) { + Text(stringResource(R.string.leave_review)) + } + } + } + } + } + + item { SectionHeader(stringResource(R.string.leave_balances)) } + item { + BalanceRow(balances = state.overview.balances, typeOf = { state.overview.typeOf(it) }) + } + + item { SectionHeader(stringResource(R.string.leave_my_requests)) } + if (state.overview.myRequests.isEmpty()) { + item { + EmptyState( + icon = Icons.Filled.BeachAccess, + title = stringResource(R.string.leave_empty_title), + message = stringResource(R.string.leave_empty_msg), + modifier = Modifier.height(280.dp), + ) + } + } else { + items(state.overview.myRequests, key = { it.id }) { request -> + RequestCard( + request = request, + type = state.overview.typeOf(request.leaveTypeId), + onCancel = { onCancelRequest(request.id) }, + ) + } + } + item { Spacer(Modifier.height(80.dp)) } // clear the FAB + } +} + +@Composable +private fun BalanceRow( + balances: List, + typeOf: (String) -> LeaveType?, +) { + if (balances.isEmpty()) { + Text( + text = stringResource(R.string.leave_balances_after_sync), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + return + } + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(balances, key = { it.id }) { balance -> + val type = typeOf(balance.leaveTypeId) + Card { + Column(Modifier.padding(12.dp)) { + Text( + text = localizedDigits("%.1f".format(balance.availableDays)), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = type?.displayName() ?: stringResource(R.string.leave_generic_type), + style = MaterialTheme.typography.labelMedium, + ) + if (balance.pendingDays > 0) { + Text( + text = localizedDigits( + stringResource( + R.string.leave_days_pending, + "%.1f".format(balance.pendingDays), + ), + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun RequestCard( + request: LeaveRequest, + type: LeaveType?, + onCancel: () -> Unit, +) { + Card( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + type?.let { + ColorDotChip( + text = it.displayName(), + dotColor = parseHexColor(it.colorHex), + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = formatShamsiRange(request.startDate, request.endDate) + + " · " + + localizedDigits( + stringResource( + R.string.leave_days_count, + "%.1f".format(request.days), + ), + ), + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = request.reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + ) + } + StatusChip(text = request.status.label(), tone = request.status.tone()) + } + if (request.syncStatus == SyncStatus.PENDING) { + Text( + text = stringResource(R.string.leave_waiting_sync), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (request.syncStatus == SyncStatus.FAILED) { + Text( + text = stringResource(R.string.leave_sync_failed), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + if (request.status == LeaveStatus.PENDING && request.syncStatus == SyncStatus.SYNCED) { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.leave_cancel_request)) + } + } + } + } +} + +@Composable +internal fun LeaveStatus.label(): String = stringResource( + when (this) { + LeaveStatus.DRAFT -> R.string.leave_status_draft + LeaveStatus.PENDING -> R.string.leave_status_pending + LeaveStatus.APPROVED -> R.string.leave_status_approved + LeaveStatus.REJECTED -> R.string.leave_status_rejected + LeaveStatus.CANCELLED -> R.string.leave_status_cancelled + }, +) + +internal fun LeaveStatus.tone(): ChipTone = when (this) { + LeaveStatus.APPROVED -> ChipTone.POSITIVE + LeaveStatus.PENDING, LeaveStatus.DRAFT -> ChipTone.WARNING + LeaveStatus.REJECTED -> ChipTone.NEGATIVE + LeaveStatus.CANCELLED -> ChipTone.NEUTRAL +} + +internal fun parseHexColor(hex: String): Color = try { + Color(android.graphics.Color.parseColor(hex)) +} catch (_: IllegalArgumentException) { + Color(0xFF607D8B) +} diff --git a/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewViewModel.kt b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewViewModel.kt new file mode 100644 index 0000000..958f2ac --- /dev/null +++ b/feature/leave/src/main/kotlin/app/worktrack/feature/leave/overview/LeaveOverviewViewModel.kt @@ -0,0 +1,63 @@ +package app.worktrack.feature.leave.overview + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.result.AppError +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.domain.usecase.auth.ObserveSessionUseCase +import app.worktrack.core.domain.usecase.leave.CancelLeaveRequestUseCase +import app.worktrack.core.domain.usecase.leave.LeaveOverview +import app.worktrack.core.domain.usecase.leave.ObserveLeaveOverviewUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class LeaveOverviewUiState( + val overview: LeaveOverview = LeaveOverview(emptyList(), emptyList(), emptyList()), + val isApprover: Boolean = false, +) + +sealed interface LeaveOverviewEffect { + data object Cancelled : LeaveOverviewEffect + data class Failed(val error: AppError) : LeaveOverviewEffect +} + +@HiltViewModel +class LeaveOverviewViewModel @Inject constructor( + observeOverview: ObserveLeaveOverviewUseCase, + observeSession: ObserveSessionUseCase, + private val cancelRequest: CancelLeaveRequestUseCase, +) : ViewModel() { + + private val _effects = Channel(Channel.BUFFERED) + val effects = _effects.receiveAsFlow() + + val uiState: StateFlow = combine( + observeOverview(), + observeSession(), + ) { overview, session -> + LeaveOverviewUiState( + overview = overview, + isApprover = session?.isApprover == true, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = LeaveOverviewUiState(), + ) + + fun onCancelRequest(requestId: String) { + viewModelScope.launch { + when (val result = cancelRequest(requestId)) { + is AppResult.Success -> _effects.send(LeaveOverviewEffect.Cancelled) + is AppResult.Failure -> _effects.send(LeaveOverviewEffect.Failed(result.error)) + } + } + } +} diff --git a/feature/leave/src/main/res/values-en/strings.xml b/feature/leave/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..bd97d08 --- /dev/null +++ b/feature/leave/src/main/res/values-en/strings.xml @@ -0,0 +1,53 @@ + + + Apply + Team requests are waiting for you + Review + Balances + My requests + No leave requests yet + Tap Apply to request time off. + Balances appear after your first sync. + %1$s days pending + %1$s days + Waiting to sync… + Sync failed — the server rejected this request + Cancel request + Leave + Request cancelled + + Draft + Pending + Approved + Rejected + Cancelled + + Apply for leave + Leave type + Dates + Start date + End date + Half first day + Half last day + ≈ %1$s days (Fridays and public holidays are settled on approval) + Reason + Why do you need this leave? + Submit request + Choose a leave type + Choose a start date + Choose an end date + A reason is required + + Approvals + All caught up + No leave requests are waiting for your decision. + Approve + Reject + Reject request + Tell %1$s why this request is being rejected. + Reason + Request approved + Request rejected + Annual leave + Sick leave + diff --git a/feature/leave/src/main/res/values-ps/strings.xml b/feature/leave/src/main/res/values-ps/strings.xml new file mode 100644 index 0000000..4ef9f72 --- /dev/null +++ b/feature/leave/src/main/res/values-ps/strings.xml @@ -0,0 +1,53 @@ + + + غوښتنه + د ټیم غوښتنې ستاسو په تمه دي + کتنه + بیلانسونه + زما غوښتنې + تر اوسه غوښتنه نه لرئ + د رخصتۍ غوښتنې لپاره د غوښتنې تڼۍ کېکاږئ. + بیلانسونه د لومړي همغږي کولو وروسته ښکاري. + %1$s ورځې په تمه + %1$s ورځې + د همغږۍ په تمه… + همغږي ناکامه شوه — سرور دا غوښتنه رد کړه + غوښتنه لغوه کړئ + رخصتي + غوښتنه لغوه شوه + + مسوده + په تمه + تایید شوې + رد شوې + لغوه شوې + + د رخصتۍ غوښتنه + د رخصتۍ ډول + نېټې + د پیل نېټه + د پای نېټه + لومړۍ نیمه ورځ + وروستۍ نیمه ورځ + ≈ %1$s ورځې (جمعې او عمومي رخصتۍ د تایید پر مهال حسابېږي) + دلیل + ولې دې رخصتۍ ته اړتیا لرئ؟ + غوښتنه واستوئ + د رخصتۍ ډول وټاکئ + د پیل نېټه وټاکئ + د پای نېټه وټاکئ + دلیل لیکل اړین دي + + تاییدونه + ټول کتل شوي + هېڅ د رخصتۍ غوښتنه ستاسو د پرېکړې په تمه نه ده. + تایید + رد + غوښتنه رد کړئ + %1$s ته ووایاست چې دا غوښتنه ولې ردېږي. + دلیل + غوښتنه تایید شوه + غوښتنه رد شوه + کلنۍ رخصتي + د ناروغۍ رخصتي + diff --git a/feature/leave/src/main/res/values/strings.xml b/feature/leave/src/main/res/values/strings.xml new file mode 100644 index 0000000..ad9b372 --- /dev/null +++ b/feature/leave/src/main/res/values/strings.xml @@ -0,0 +1,53 @@ + + + درخواست + درخواست‌های تیم منتظر شماست + بررسی + بیلانس‌ها + درخواست‌های من + هنوز درخواستی ندارید + برای درخواست رخصتی، دکمهٔ درخواست را بزنید. + بیلانس‌ها بعد از اولین همگام‌سازی نمایش داده می‌شود. + %1$s روز در انتظار + %1$s روز + در انتظار همگام‌سازی… + همگام‌سازی ناکام شد — سرور این درخواست را رد کرد + لغو درخواست + رخصتی + درخواست لغو شد + + مسوده + در انتظار + تایید شده + رد شده + لغو شده + + درخواست رخصتی + نوع رخصتی + تاریخ‌ها + تاریخ شروع + تاریخ ختم + نیم روز اول + نیم روز آخر + ≈ %1$s روز (جمعه‌ها و رخصتی‌های عمومی هنگام تایید حساب می‌شود) + دلیل + چرا به این رخصتی نیاز دارید؟ + ارسال درخواست + نوع رخصتی را انتخاب کنید + تاریخ شروع را انتخاب کنید + تاریخ ختم را انتخاب کنید + نوشتن دلیل لازم است + + تاییدی‌ها + همه بررسی شده + هیچ درخواست رخصتی منتظر فیصلهٔ شما نیست. + تایید + رد + رد درخواست + به %1$s بگویید چرا این درخواست رد می‌شود. + دلیل + درخواست تایید شد + درخواست رد شد + رخصتی سالانه + رخصتی مریضی + diff --git a/feature/leave/src/test/kotlin/app/worktrack/feature/leave/LeaveTypeNamesTest.kt b/feature/leave/src/test/kotlin/app/worktrack/feature/leave/LeaveTypeNamesTest.kt new file mode 100644 index 0000000..2d04bf2 --- /dev/null +++ b/feature/leave/src/test/kotlin/app/worktrack/feature/leave/LeaveTypeNamesTest.kt @@ -0,0 +1,27 @@ +package app.worktrack.feature.leave + +import app.worktrack.core.model.LeaveType +import java.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class LeaveTypeNamesTest { + + private fun type(code: String, name: String) = LeaveType( + id = code.lowercase(), companyId = "c1", name = name, code = code, colorHex = "#000000", + isPaid = true, requiresAttachment = false, active = true, updatedAt = Instant.EPOCH, + ) + + @Test + fun builtInTypesWithTheSeededNameAreTranslated() { + assertEquals(R.string.leave_type_annual, builtInNameRes(type("ANNUAL", "رخصتی سالانه"))) + assertEquals(R.string.leave_type_sick, builtInNameRes(type("SICK", "رخصتی مریضی"))) + } + + @Test + fun aNameTheCompanyTypedIsKept() { + assertNull(builtInNameRes(type("ANNUAL", "رخصتی تفریحی"))) + assertNull(builtInNameRes(type("HAJJ", "رخصتی حج"))) + } +} diff --git a/feature/payslips/build.gradle.kts b/feature/payslips/build.gradle.kts new file mode 100644 index 0000000..cd9396a --- /dev/null +++ b/feature/payslips/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.worktrack.android.feature) +} + +android { + namespace = "app.worktrack.feature.payslips" +} diff --git a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipLineNames.kt b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipLineNames.kt new file mode 100644 index 0000000..eb29742 --- /dev/null +++ b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipLineNames.kt @@ -0,0 +1,16 @@ +package app.worktrack.feature.payslips + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import app.worktrack.core.model.PayslipLine + +/** Payroll names these three lines in Dari itself; company components keep their own names. */ +internal fun builtInNameRes(line: PayslipLine): Int? = when (line.componentCode) { + "BASIC" -> R.string.pay_basic + "LOP" -> R.string.pay_absence + "TAX" -> R.string.pay_income_tax + else -> null +} + +@Composable +internal fun PayslipLine.displayName(): String = builtInNameRes(this)?.let { stringResource(it) } ?: componentName diff --git a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsScreen.kt b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsScreen.kt new file mode 100644 index 0000000..61fcee3 --- /dev/null +++ b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsScreen.kt @@ -0,0 +1,190 @@ +package app.worktrack.feature.payslips + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.ReceiptLong +import androidx.compose.material3.Card +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.EmptyState +import app.worktrack.core.designsystem.l10n.formatShamsiMonthYear +import app.worktrack.core.designsystem.l10n.localizedDigits +import app.worktrack.core.designsystem.l10n.shamsiMonthName +import app.worktrack.core.model.Payslip + +@Composable +fun PayslipsRoute( + onPayslipClick: (String) -> Unit, + viewModel: PayslipsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + Column(Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = viewModel::onPreviousYear) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = stringResource(R.string.pay_prev_year), + ) + } + Text( + text = localizedDigits(state.year.toString()), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + ) + IconButton(onClick = viewModel::onNextYear, enabled = state.canGoForward) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = stringResource(R.string.pay_next_year), + ) + } + } + + // With nothing paid in the whole year there is nothing to filter, and a + // row of twelve dead chips above the empty state is just noise. It stays + // when a month is selected, so the filter can always be cleared. + if (state.monthsWithPayslips.isNotEmpty() || state.month != null) { + MonthFilter( + selected = state.month, + available = state.monthsWithPayslips, + onSelect = viewModel::onMonthSelected, + ) + } + + if (state.payslips.isEmpty()) { + EmptyState( + icon = Icons.AutoMirrored.Filled.ReceiptLong, + title = if (state.month != null) { + // Naming the month is the difference between "nothing here" + // and "nothing for the month you picked". + stringResource( + R.string.pay_no_payslips_month_title, + formatShamsiMonthYear(state.year, state.month!!), + ) + } else { + stringResource( + R.string.pay_no_payslips_title, + localizedDigits(state.year.toString()), + ) + }, + message = stringResource(R.string.pay_no_payslips_msg), + ) + } else { + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(state.payslips, key = { it.id }) { payslip -> + PayslipCard(payslip = payslip, onClick = { onPayslipClick(payslip.id) }) + } + } + } + } +} + +/** + * Month picker for the selected year. + * + * Months without a payslip stay visible but disabled, so the row doubles as an + * at-a-glance answer to "which months have I been paid for?" — the question + * that otherwise means scrolling the whole list. + */ +@Composable +private fun MonthFilter( + selected: Int?, + available: Set, + onSelect: (Int?) -> Unit, +) { + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + FilterChip( + selected = selected == null, + onClick = { onSelect(null) }, + label = { Text(stringResource(R.string.pay_all_months)) }, + ) + } + items(12) { index -> + val month = index + 1 + FilterChip( + selected = selected == month, + enabled = month in available, + onClick = { onSelect(if (selected == month) null else month) }, + label = { Text(shamsiMonthName(month)) }, + ) + } + } +} + +@Composable +private fun PayslipCard(payslip: Payslip, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clickable(onClick = onClick), + ) { + Row( + Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + // Payroll periods are Solar Hijri months (e.g. "سرطان ۱۴۰۵"). + text = formatShamsiMonthYear(payslip.periodYear, payslip.periodMonth), + style = MaterialTheme.typography.titleSmall, + ) + val worked = localizedDigits( + stringResource(R.string.pay_worked_days, "%.1f".format(payslip.workedDays)), + ) + val lop = if (payslip.lopDays > 0) { + " · " + localizedDigits( + stringResource(R.string.pay_lop_days, "%.1f".format(payslip.lopDays)), + ) + } else { + "" + } + Text( + text = worked + lop, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = localizedDigits("${payslip.currency} ${"%,.2f".format(payslip.net)}"), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsViewModel.kt b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsViewModel.kt new file mode 100644 index 0000000..69281bf --- /dev/null +++ b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/PayslipsViewModel.kt @@ -0,0 +1,101 @@ +package app.worktrack.feature.payslips + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.common.time.SolarHijri +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.domain.repository.PayslipRepository +import app.worktrack.core.domain.usecase.payslip.ObservePayslipsUseCase +import app.worktrack.core.model.Payslip +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Payroll periods are **Solar Hijri** months/years for Afghan tenants: + * periodYear/periodMonth on payslips carry Shamsi values (e.g. 1405/4 = Saratan). + */ +data class PayslipsUiState( + val year: Int, + val payslips: List = emptyList(), + val canGoForward: Boolean = false, + /** Selected Shamsi month (1–12), or null for the whole year. */ + val month: Int? = null, + /** Months of [year] that have a payslip, so the picker can dim the rest. */ + val monthsWithPayslips: Set = emptySet(), +) + +@HiltViewModel +class PayslipsViewModel @Inject constructor( + observePayslips: ObservePayslipsUseCase, + private val payslipRepository: PayslipRepository, + private val timeProvider: TimeProvider, + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private fun currentShamsiYear(): Int = SolarHijri.today(timeProvider).year + + private val year: StateFlow = + savedStateHandle.getStateFlow(KEY_YEAR, currentShamsiYear()) + + /** 0 means "the whole year"; SavedStateHandle keeps no nullable Int. */ + private val month: StateFlow = + savedStateHandle.getStateFlow(KEY_MONTH, ALL_MONTHS) + + val uiState: StateFlow = year + .flatMapLatest { selected -> observePayslips(selected) } + .combine(year) { slips, selected -> slips to selected } + .combine(month) { (slips, selected), selectedMonth -> + PayslipsUiState( + year = selected, + // Filtering here rather than in the query keeps the month picker + // able to show which months exist without a second read. + payslips = slips.filter { + selectedMonth == ALL_MONTHS || it.periodMonth == selectedMonth + }, + canGoForward = selected < currentShamsiYear(), + month = selectedMonth.takeIf { it != ALL_MONTHS }, + monthsWithPayslips = slips.map { it.periodMonth }.toSet(), + ) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = PayslipsUiState(year = year.value), + ) + + init { + // Historic years are outside the delta-sync hot window; fetch on open. + viewModelScope.launch { payslipRepository.refresh(year.value) } + } + + fun onPreviousYear() = shiftYear(-1) + + fun onNextYear() = shiftYear(+1) + + private fun shiftYear(delta: Int) { + val target = year.value + delta + if (target > currentShamsiYear()) return + savedStateHandle[KEY_YEAR] = target + // Changing year keeps whichever month is selected, so stepping back a + // year lands on the same month rather than resetting to the full list. + viewModelScope.launch { payslipRepository.refresh(target) } + } + + /** Null selects the whole year. */ + fun onMonthSelected(month: Int?) { + savedStateHandle[KEY_MONTH] = month ?: ALL_MONTHS + } + + private companion object { + const val KEY_YEAR = "year" + const val KEY_MONTH = "month" + const val ALL_MONTHS = 0 + } +} diff --git a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailScreen.kt b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailScreen.kt new file mode 100644 index 0000000..41af2d1 --- /dev/null +++ b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailScreen.kt @@ -0,0 +1,152 @@ +package app.worktrack.feature.payslips.detail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.FullScreenLoading +import app.worktrack.core.designsystem.component.SectionHeader +import app.worktrack.core.designsystem.component.WtTopBar +import app.worktrack.core.designsystem.l10n.formatShamsiMonthYear +import app.worktrack.core.designsystem.l10n.localizedDigits +import app.worktrack.core.model.PayComponentType +import app.worktrack.core.model.Payslip +import app.worktrack.feature.payslips.R +import app.worktrack.feature.payslips.displayName + +@Composable +fun PayslipDetailRoute( + onBack: () -> Unit, + viewModel: PayslipDetailViewModel = hiltViewModel(), +) { + val payslip by viewModel.payslip.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + WtTopBar( + title = payslip?.let { formatShamsiMonthYear(it.periodYear, it.periodMonth) } + ?: stringResource(R.string.pay_payslip), + onBack = onBack, + ) + }, + ) { padding -> + when (val slip = payslip) { + null -> FullScreenLoading(Modifier.padding(padding)) + else -> PayslipDetail(payslip = slip, modifier = Modifier.padding(padding)) + } + } +} + +@Composable +private fun PayslipDetail(payslip: Payslip, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 32.dp), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Column(Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.pay_net_pay), + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = localizedDigits("${payslip.currency} ${"%,.2f".format(payslip.net)}"), + style = MaterialTheme.typography.headlineMedium, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = localizedDigits( + stringResource( + R.string.pay_gross_minus, + "%,.2f".format(payslip.gross), + "%,.2f".format(payslip.totalDeductions), + ), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + val earnings = payslip.lines.filter { it.type == PayComponentType.EARNING } + val deductions = payslip.lines.filter { it.type == PayComponentType.DEDUCTION } + + if (earnings.isNotEmpty()) { + SectionHeader(stringResource(R.string.pay_earnings)) + LinesCard(lines = earnings.map { it.displayName() to it.amount }, currency = payslip.currency) + } + if (deductions.isNotEmpty()) { + SectionHeader(stringResource(R.string.pay_deductions)) + LinesCard(lines = deductions.map { it.displayName() to it.amount }, currency = payslip.currency) + } + + SectionHeader(stringResource(R.string.pay_attendance_summary)) + LinesCard( + lines = listOf( + stringResource(R.string.pay_worked_days_label) to payslip.workedDays, + stringResource(R.string.pay_paid_leave_label) to payslip.paidLeaveDays, + stringResource(R.string.pay_lop_label) to payslip.lopDays, + ), + currency = null, + ) + } +} + +@Composable +private fun LinesCard(lines: List>, currency: String?) { + Card( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Column(Modifier.padding(12.dp)) { + lines.forEachIndexed { index, (name, amount) -> + if (index > 0) HorizontalDivider(Modifier.padding(vertical = 8.dp)) + Row { + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = localizedDigits( + if (currency != null) { + "$currency ${"%,.2f".format(amount)}" + } else { + "%.1f".format(amount) + }, + ), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} diff --git a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailViewModel.kt b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailViewModel.kt new file mode 100644 index 0000000..cff0635 --- /dev/null +++ b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/detail/PayslipDetailViewModel.kt @@ -0,0 +1,30 @@ +package app.worktrack.feature.payslips.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.domain.usecase.payslip.ObservePayslipDetailUseCase +import app.worktrack.core.model.Payslip +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn + +@HiltViewModel +class PayslipDetailViewModel @Inject constructor( + observePayslipDetail: ObservePayslipDetailUseCase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val payslipId: String = checkNotNull(savedStateHandle[ARG_PAYSLIP_ID]) { + "payslipId navigation argument is required" + } + + val payslip: StateFlow = observePayslipDetail(payslipId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + companion object { + const val ARG_PAYSLIP_ID = "payslipId" + } +} diff --git a/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/navigation/PayslipsNavigation.kt b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/navigation/PayslipsNavigation.kt new file mode 100644 index 0000000..0dea1bb --- /dev/null +++ b/feature/payslips/src/main/kotlin/app/worktrack/feature/payslips/navigation/PayslipsNavigation.kt @@ -0,0 +1,38 @@ +package app.worktrack.feature.payslips.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import androidx.navigation.navDeepLink +import app.worktrack.feature.payslips.PayslipsRoute +import app.worktrack.feature.payslips.detail.PayslipDetailRoute +import app.worktrack.feature.payslips.detail.PayslipDetailViewModel + +const val PAYSLIPS_ROUTE = "payslips" +const val PAYSLIP_DETAIL_ROUTE = "payslips/{${PayslipDetailViewModel.ARG_PAYSLIP_ID}}" + +fun payslipDetailRoute(payslipId: String) = "payslips/$payslipId" + +fun NavGraphBuilder.payslipScreens(navController: NavController) { + composable(route = PAYSLIPS_ROUTE) { + PayslipsRoute( + onPayslipClick = { id -> navController.navigate(payslipDetailRoute(id)) }, + ) + } + + composable( + route = PAYSLIP_DETAIL_ROUTE, + arguments = listOf( + navArgument(PayslipDetailViewModel.ARG_PAYSLIP_ID) { type = NavType.StringType }, + ), + deepLinks = listOf( + navDeepLink { + uriPattern = "worktrack://payslips/{${PayslipDetailViewModel.ARG_PAYSLIP_ID}}" + }, + ), + ) { + PayslipDetailRoute(onBack = { navController.popBackStack() }) + } +} diff --git a/feature/payslips/src/main/res/values-en/strings.xml b/feature/payslips/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..9904d1d --- /dev/null +++ b/feature/payslips/src/main/res/values-en/strings.xml @@ -0,0 +1,23 @@ + + + Payslip + No payslips for %1$s + Payslips appear here once payroll is finalized. + Previous year + Next year + Worked %1$s days + LOP %1$s days + Net pay + Gross %1$s − Deductions %2$s + Earnings + Deductions + Attendance summary + Worked days + Paid leave days + Loss-of-pay days + All months + No payslip for %1$s + Basic salary + Absence deduction + Income tax + diff --git a/feature/payslips/src/main/res/values-ps/strings.xml b/feature/payslips/src/main/res/values-ps/strings.xml new file mode 100644 index 0000000..fcd6385 --- /dev/null +++ b/feature/payslips/src/main/res/values-ps/strings.xml @@ -0,0 +1,23 @@ + + + د معاش فیش + د %1$s کال لپاره د معاش فیش نشته + د معاش فیشونه د معاشاتو له نهایي کېدو وروسته ښکاري. + پخوانی کال + راتلونکی کال + %1$s ورځې کار + %1$s ورځې د معاش کسر + خالص معاش + ناخالص %1$s − کسرات %2$s + عواید + کسرات + د حاضرۍ لنډیز + د کار ورځې + له معاش سره د رخصتۍ ورځې + د معاش کسر ورځې + ټولې میاشتې + د %1$s لپاره د معاش فیش نشته + اساسي معاش + د غیرحاضرۍ کسر + د معاش مالیه + diff --git a/feature/payslips/src/main/res/values/strings.xml b/feature/payslips/src/main/res/values/strings.xml new file mode 100644 index 0000000..715cc6c --- /dev/null +++ b/feature/payslips/src/main/res/values/strings.xml @@ -0,0 +1,23 @@ + + + فیش معاش + فیش معاشی برای سال %1$s نیست + فیش‌های معاش بعد از نهایی شدن معاشات نمایش داده می‌شود. + سال قبلی + سال بعدی + %1$s روز کارکرد + %1$s روز کسر معاش + معاش خالص + ناخالص %1$s − کسرات %2$s + عواید + کسرات + خلاصهٔ حاضری + روزهای کارکرد + روزهای رخصتی با معاش + روزهای کسر معاش + همه ماه‌ها + فیش معاشی برای %1$s نیست + معاش اساسی + کسر غیرحاضری + مالیهٔ معاش + diff --git a/feature/payslips/src/test/kotlin/app/worktrack/feature/payslips/PayslipLineNamesTest.kt b/feature/payslips/src/test/kotlin/app/worktrack/feature/payslips/PayslipLineNamesTest.kt new file mode 100644 index 0000000..51a3a4e --- /dev/null +++ b/feature/payslips/src/test/kotlin/app/worktrack/feature/payslips/PayslipLineNamesTest.kt @@ -0,0 +1,24 @@ +package app.worktrack.feature.payslips + +import app.worktrack.core.model.PayComponentType +import app.worktrack.core.model.PayslipLine +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PayslipLineNamesTest { + + private fun line(code: String, name: String) = PayslipLine(code, name, PayComponentType.EARNING, 1.0) + + @Test + fun payrollWrittenLinesAreTranslated() { + assertEquals(R.string.pay_basic, builtInNameRes(line("BASIC", "معاش اساسی"))) + assertEquals(R.string.pay_absence, builtInNameRes(line("LOP", "کسر غیرحاضری"))) + assertEquals(R.string.pay_income_tax, builtInNameRes(line("TAX", "مالیهٔ معاش"))) + } + + @Test + fun companyComponentsKeepTheirName() { + assertNull(builtInNameRes(line("TRANSPORT", "کمک ترانسپورت"))) + } +} diff --git a/feature/payslips/src/test/kotlin/app/worktrack/feature/payslips/PayslipsViewModelTest.kt b/feature/payslips/src/test/kotlin/app/worktrack/feature/payslips/PayslipsViewModelTest.kt new file mode 100644 index 0000000..cada93d --- /dev/null +++ b/feature/payslips/src/test/kotlin/app/worktrack/feature/payslips/PayslipsViewModelTest.kt @@ -0,0 +1,148 @@ +package app.worktrack.feature.payslips + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import app.worktrack.core.common.result.AppResult +import app.worktrack.core.common.time.TimeProvider +import app.worktrack.core.domain.repository.PayslipRepository +import app.worktrack.core.domain.usecase.payslip.ObservePayslipsUseCase +import app.worktrack.core.model.Payslip +import app.worktrack.core.model.PayslipStatus +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.time.Instant +import java.time.ZoneId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +/** + * The month filter cannot be seen on a tenant with no payroll history, and + * running payroll to produce one is a financial operation — so the filtering + * itself is covered here. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PayslipsViewModelTest { + + private val repository = mockk(relaxed = true) + private val timeProvider = object : TimeProvider { + // 2026-07-27 → 1405/05 (Asad) in the Solar Hijri calendar. + override fun now(): Instant = Instant.parse("2026-07-27T09:00:00Z") + override fun zone(): ZoneId = ZoneId.of("Asia/Kabul") + } + + @Before + fun setUp() = Dispatchers.setMain(StandardTestDispatcher()) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun payslip(month: Int) = Payslip( + id = "p$month", + companyId = "c1", + runId = "1405_$month", + employeeId = "e1", + periodYear = 1405, + periodMonth = month, + currency = "AFN", + gross = 30000.0, + totalDeductions = 1000.0, + net = 29000.0, + workedDays = 26.0, + paidLeaveDays = 0.0, + lopDays = 0.0, + overtimeMinutes = 0, + status = PayslipStatus.PAID, + pdfUrl = null, + lines = emptyList(), + updatedAt = Instant.parse("2026-07-01T00:00:00Z"), + ) + + private fun viewModel(months: List): PayslipsViewModel { + every { repository.observePayslips(any()) } returns flowOf(months.map(::payslip)) + coEvery { repository.refresh(any()) } returns AppResult.Success(Unit) + return PayslipsViewModel( + observePayslips = ObservePayslipsUseCase(repository), + payslipRepository = repository, + timeProvider = timeProvider, + savedStateHandle = SavedStateHandle(), + ) + } + + /** stateIn emits a placeholder before the repository flow arrives. */ + private suspend fun ReceiveTurbine.awaitLoaded(): PayslipsUiState { + var state = awaitItem() + while (state.monthsWithPayslips.isEmpty()) state = awaitItem() + return state + } + + @Test + fun `shows every month of the year until one is picked`() = runTest { + viewModel(listOf(1, 2, 4)).uiState.test { + val state = awaitLoaded() + assertNull(state.month) + assertEquals(3, state.payslips.size) + } + } + + @Test + fun `narrows the list to the selected month`() = runTest { + val vm = viewModel(listOf(1, 2, 4)) + vm.uiState.test { + awaitLoaded() + vm.onMonthSelected(2) + val state = awaitItem() + assertEquals(2, state.month) + assertEquals(listOf(2), state.payslips.map { it.periodMonth }) + } + } + + @Test + fun `reports which months have a payslip so the picker can dim the rest`() = runTest { + val vm = viewModel(listOf(1, 2, 4)) + vm.uiState.test { + assertEquals(setOf(1, 2, 4), awaitLoaded().monthsWithPayslips) + + // Narrowing must not shrink the picker to just the chosen month. + vm.onMonthSelected(2) + assertEquals(setOf(1, 2, 4), awaitItem().monthsWithPayslips) + } + } + + @Test + fun `selecting a month with no payslip empties the list, not the picker`() = runTest { + val vm = viewModel(listOf(1, 2, 4)) + vm.uiState.test { + awaitLoaded() + vm.onMonthSelected(7) + val state = awaitItem() + assertEquals(emptyList(), state.payslips) + assertEquals(setOf(1, 2, 4), state.monthsWithPayslips) + } + } + + @Test + fun `clearing the filter restores the whole year`() = runTest { + val vm = viewModel(listOf(1, 2, 4)) + vm.uiState.test { + awaitLoaded() + vm.onMonthSelected(2) + awaitItem() + vm.onMonthSelected(null) + val state = awaitItem() + assertNull(state.month) + assertEquals(3, state.payslips.size) + } + } +} diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts new file mode 100644 index 0000000..9826265 --- /dev/null +++ b/feature/profile/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.worktrack.android.feature) +} + +android { + namespace = "app.worktrack.feature.profile" +} + +dependencies { + // AppCompatDelegate drives the in-app language switch (Dari/Pashto/English). + implementation(libs.androidx.appcompat) + // BiometricManager.canAuthenticate() to gate the fingerprint-lock toggle. + implementation(libs.androidx.biometric) +} diff --git a/feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileScreen.kt b/feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileScreen.kt new file mode 100644 index 0000000..6eb8bb0 --- /dev/null +++ b/feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileScreen.kt @@ -0,0 +1,355 @@ +package app.worktrack.feature.profile + +import androidx.appcompat.app.AppCompatDelegate +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.FilterChip +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.os.LocaleListCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.worktrack.core.designsystem.component.ChipTone +import app.worktrack.core.designsystem.component.FullScreenLoading +import app.worktrack.core.designsystem.component.SectionHeader +import app.worktrack.core.designsystem.component.StatusChip +import app.worktrack.core.designsystem.component.WtPrimaryButton +import app.worktrack.core.designsystem.component.WtSecondaryButton +import app.worktrack.core.designsystem.l10n.appLocale +import app.worktrack.core.designsystem.l10n.formatShamsiDateTime +import app.worktrack.core.designsystem.l10n.localizedDigits +import app.worktrack.core.model.RoleCode +import app.worktrack.core.model.SyncState +import app.worktrack.core.model.UserSession + +/** App language options; tags feed AppCompatDelegate.setApplicationLocales. */ +private enum class AppLanguage(val tag: String, val labelRes: Int) { + DARI("fa-AF", R.string.prof_lang_dari), + PASHTO("ps-AF", R.string.prof_lang_pashto), + ENGLISH("en", R.string.prof_lang_english), +} + +@Composable +fun ProfileRoute( + onPayslipsClick: () -> Unit, + viewModel: ProfileViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val session = state.session + val context = LocalContext.current + + if (session == null) { + FullScreenLoading() + return + } + ProfileScreen( + session = session, + syncState = state.syncState, + isSigningOut = state.isSigningOut, + biometricEnabled = state.biometricEnabled, + onBiometricToggle = viewModel::onBiometricToggle, + onPayslipsClick = onPayslipsClick, + onLanguageSelect = { language -> + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.forLanguageTags(language), + ) + }, + onSyncNow = viewModel::onSyncNow, + onSignOut = viewModel::onSignOut, + onCallSupport = { + // ACTION_DIAL opens the dialler with the number filled in rather + // than placing the call, so it needs no permission and the employee + // still decides. + context.startActivity( + Intent(Intent.ACTION_DIAL, Uri.parse("tel:$SUPPORT_PHONE")), + ) + }, + ) +} + +private fun Context.installedVersionName(): String = + runCatching { packageManager.getPackageInfo(packageName, 0).versionName } + .getOrNull() ?: "—" + +/** Linumic support, as printed on linumic.com and in the handover pack. */ +private const val SUPPORT_PHONE = "+93793817977" + +@Composable +internal fun ProfileScreen( + session: UserSession, + syncState: SyncState?, + isSigningOut: Boolean, + biometricEnabled: Boolean, + onBiometricToggle: (Boolean) -> Unit, + onPayslipsClick: () -> Unit, + onLanguageSelect: (String) -> Unit, + onSyncNow: () -> Unit, + onSignOut: () -> Unit, + onCallSupport: () -> Unit, +) { + // A library module has no BuildConfig.VERSION_NAME; the installed package + // is the honest source anyway — it is the build the employee is running. + val appVersion = LocalContext.current.installedVersionName() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 32.dp), + ) { + Card( + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Column(Modifier.padding(16.dp)) { + Text(session.displayName, style = MaterialTheme.typography.titleLarge) + Text( + text = session.email, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = session.companyName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + session.roles.forEach { role -> + StatusChip(text = role.label(), tone = ChipTone.NEUTRAL) + } + } + } + } + + SectionHeader(stringResource(R.string.prof_language)) + LanguageRow(onLanguageSelect = onLanguageSelect) + + SectionHeader(stringResource(R.string.prof_security)) + BiometricRow(enabled = biometricEnabled, onToggle = onBiometricToggle) + + SectionHeader(stringResource(R.string.prof_payroll)) + WtSecondaryButton( + text = stringResource(R.string.prof_my_payslips), + onClick = onPayslipsClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(8.dp)) + + SectionHeader(stringResource(R.string.prof_sync)) + Card( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Column(Modifier.padding(16.dp)) { + SyncStatusRow(syncState) + Spacer(Modifier.height(12.dp)) + WtSecondaryButton( + text = stringResource(R.string.prof_sync_now), + onClick = onSyncNow, + ) + } + } + + Spacer(Modifier.height(16.dp)) + SectionHeader(stringResource(R.string.prof_about)) + Card( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Column(Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.prof_about_vendor), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.prof_version, appVersion), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + // Support is a phone call here far more often than an email, so + // the number is the one that dials rather than the one to read. + WtSecondaryButton( + text = stringResource(R.string.prof_call_support), + onClick = onCallSupport, + ) + } + } + + Spacer(Modifier.height(24.dp)) + WtPrimaryButton( + text = stringResource(R.string.prof_sign_out), + onClick = onSignOut, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + loading = isSigningOut, + ) + } +} + +@Composable +private fun BiometricRow(enabled: Boolean, onToggle: (Boolean) -> Unit) { + val context = LocalContext.current + val available = remember { + BiometricManager.from(context).canAuthenticate(BIOMETRIC_STRONG or BIOMETRIC_WEAK) == + BiometricManager.BIOMETRIC_SUCCESS + } + Card( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = stringResource(R.string.prof_biometric), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource( + if (available) R.string.prof_biometric_desc else R.string.prof_biometric_unavailable, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = enabled && available, + onCheckedChange = { if (available) onToggle(it) }, + enabled = available, + ) + } + } +} + +@Composable +private fun LanguageRow(onLanguageSelect: (String) -> Unit) { + val currentLanguage = appLocale().language + Row( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AppLanguage.entries.forEach { language -> + FilterChip( + selected = language.tag.startsWith(currentLanguage), + onClick = { onLanguageSelect(language.tag) }, + label = { Text(stringResource(language.labelRes)) }, + ) + } + } +} + +@Composable +private fun SyncStatusRow(syncState: SyncState?) { + if (syncState == null) { + Text( + text = stringResource(R.string.prof_sync_unavailable), + style = MaterialTheme.typography.bodyMedium, + ) + return + } + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text( + text = when { + syncState.isSyncing -> stringResource(R.string.prof_syncing) + syncState.pendingOperations > 0 -> localizedDigits( + stringResource( + R.string.prof_pending_changes, + syncState.pendingOperations.toString(), + ), + ) + + else -> stringResource(R.string.prof_up_to_date) + }, + style = MaterialTheme.typography.bodyMedium, + ) + syncState.lastSuccessAt?.let { + Text( + text = stringResource(R.string.prof_last_synced, formatShamsiDateTime(it)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (syncState.failedOperations > 0) { + Text( + text = localizedDigits( + stringResource( + R.string.prof_rejected_changes, + syncState.failedOperations.toString(), + ), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + StatusChip( + text = stringResource( + when { + syncState.isSyncing -> R.string.prof_chip_syncing + syncState.failedOperations > 0 -> R.string.prof_chip_attention + syncState.pendingOperations > 0 -> R.string.prof_chip_pending + else -> R.string.prof_chip_ok + }, + ), + tone = when { + syncState.failedOperations > 0 -> ChipTone.NEGATIVE + syncState.pendingOperations > 0 || syncState.isSyncing -> ChipTone.WARNING + else -> ChipTone.POSITIVE + }, + ) + } +} + +@Composable +private fun RoleCode.label(): String = stringResource( + when (this) { + RoleCode.SUPER_ADMIN -> R.string.prof_role_super_admin + RoleCode.COMPANY_ADMIN -> R.string.prof_role_company_admin + RoleCode.HR_ADMIN -> R.string.prof_role_hr_admin + RoleCode.PAYROLL_ADMIN -> R.string.prof_role_payroll_admin + RoleCode.BRANCH_MANAGER -> R.string.prof_role_branch_manager + RoleCode.TEAM_LEAD -> R.string.prof_role_team_lead + RoleCode.EMPLOYEE -> R.string.prof_role_employee + RoleCode.AUDITOR -> R.string.prof_role_auditor + RoleCode.KIOSK -> R.string.prof_role_kiosk + }, +) diff --git a/feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileViewModel.kt b/feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileViewModel.kt new file mode 100644 index 0000000..6a82992 --- /dev/null +++ b/feature/profile/src/main/kotlin/app/worktrack/feature/profile/ProfileViewModel.kt @@ -0,0 +1,72 @@ +package app.worktrack.feature.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.worktrack.core.domain.usecase.auth.ObserveBiometricLockUseCase +import app.worktrack.core.domain.usecase.auth.ObserveSessionUseCase +import app.worktrack.core.domain.usecase.auth.SetBiometricLockUseCase +import app.worktrack.core.domain.usecase.auth.SignOutUseCase +import app.worktrack.core.domain.usecase.sync.ObserveSyncStateUseCase +import app.worktrack.core.domain.usecase.sync.TriggerSyncUseCase +import app.worktrack.core.model.SyncState +import app.worktrack.core.model.UserSession +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class ProfileUiState( + val session: UserSession? = null, + val syncState: SyncState? = null, + val isSigningOut: Boolean = false, + val biometricEnabled: Boolean = false, +) + +@HiltViewModel +class ProfileViewModel @Inject constructor( + observeSession: ObserveSessionUseCase, + observeSyncState: ObserveSyncStateUseCase, + observeBiometricLock: ObserveBiometricLockUseCase, + private val setBiometricLock: SetBiometricLockUseCase, + private val signOut: SignOutUseCase, + private val triggerSync: TriggerSyncUseCase, +) : ViewModel() { + + private val signingOut = kotlinx.coroutines.flow.MutableStateFlow(false) + + val uiState: StateFlow = combine( + observeSession(), + observeSyncState(), + signingOut, + observeBiometricLock(), + ) { session, syncState, isSigningOut, biometricEnabled -> + ProfileUiState( + session = session, + syncState = syncState, + isSigningOut = isSigningOut, + biometricEnabled = biometricEnabled, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ProfileUiState(), + ) + + fun onBiometricToggle(enabled: Boolean) { + viewModelScope.launch { setBiometricLock(enabled) } + } + + fun onSyncNow() = triggerSync() + + fun onSignOut() { + if (signingOut.value) return + signingOut.value = true + viewModelScope.launch { + signOut.invoke() + // No state reset needed: clearing the session flips the root nav graph. + } + } +} diff --git a/feature/profile/src/main/kotlin/app/worktrack/feature/profile/navigation/ProfileNavigation.kt b/feature/profile/src/main/kotlin/app/worktrack/feature/profile/navigation/ProfileNavigation.kt new file mode 100644 index 0000000..599d870 --- /dev/null +++ b/feature/profile/src/main/kotlin/app/worktrack/feature/profile/navigation/ProfileNavigation.kt @@ -0,0 +1,13 @@ +package app.worktrack.feature.profile.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import app.worktrack.feature.profile.ProfileRoute + +const val PROFILE_ROUTE = "profile" + +fun NavGraphBuilder.profileScreen(onPayslipsClick: () -> Unit) { + composable(route = PROFILE_ROUTE) { + ProfileRoute(onPayslipsClick = onPayslipsClick) + } +} diff --git a/feature/profile/src/main/res/values-en/strings.xml b/feature/profile/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..5eab408 --- /dev/null +++ b/feature/profile/src/main/res/values-en/strings.xml @@ -0,0 +1,42 @@ + + + Payroll + My payslips + Language + Sync + Sync now + Syncing… + %1$s changes waiting to sync + Everything is up to date + Last synced: %1$s + %1$s changes were rejected by the server + Sync status unavailable + Sign out + + Syncing + Attention + Pending + Up to date + + System admin + Company admin + HR admin + Payroll admin + Branch manager + Team lead + Employee + Auditor + Kiosk + + دری + پښتو + English + Security + Fingerprint unlock + Require your fingerprint to open the app + No fingerprint is set up on this device + About + WorkTrack is a Linumic product — Kabul, Afghanistan. + Version %1$s + Call support + diff --git a/feature/profile/src/main/res/values-ps/strings.xml b/feature/profile/src/main/res/values-ps/strings.xml new file mode 100644 index 0000000..947f7bb --- /dev/null +++ b/feature/profile/src/main/res/values-ps/strings.xml @@ -0,0 +1,42 @@ + + + معاشات + زما د معاش فیشونه + ژبه + همغږي + اوس همغږي کړئ + همغږي کېږي… + %1$s بدلونونه د همغږۍ په تمه + هر څه تازه دي + وروستۍ همغږي: %1$s + %1$s بدلونونه سرور رد کړل + د همغږۍ حالت نشته + له حسابه ووځئ + + همغږي کېږي + پاملرنې ته اړتیا + په تمه + تازه + + د سیستم مدیر + د شرکت مدیر + د بشري منابعو مدیر + د معاشاتو مدیر + د څانګې مدیر + د ډلې مشر + کارکوونکی + پلټونکی + کیوسک + + دری + پښتو + English + امنیت + د ګوتې له لارې ننوتل + د اپ خلاصولو لپاره ستاسو ګوته اړینه ده + پدې وسیله کې کوم ګوته نه ده ثبت شوې + په اړه + ورک‌ټرک د لینومیک محصول دی — کابل، افغانستان. + بڼه %1$s + له ملاتړ سره اړیکه + diff --git a/feature/profile/src/main/res/values/strings.xml b/feature/profile/src/main/res/values/strings.xml new file mode 100644 index 0000000..a3bb567 --- /dev/null +++ b/feature/profile/src/main/res/values/strings.xml @@ -0,0 +1,43 @@ + + + معاشات + فیش‌های معاش من + زبان + همگام‌سازی + همگام‌سازی فوری + در حال همگام‌سازی… + %1$s تغییر در انتظار همگام‌سازی + همه چیز به‌روز است + آخرین همگام‌سازی: %1$s + %1$s تغییر توسط سرور رد شد + وضعیت همگام‌سازی در دسترس نیست + خروج از حساب + + در حال همگام‌سازی + نیاز به توجه + در انتظار + به‌روز + + مدیر سیستم + مدیر شرکت + مدیر منابع بشری + مدیر معاشات + مدیر شعبه + سرگروپ + کارمند + بازرس + کیوسک + + + دری + پښتو + English + امنیت + ورود با اثر انگشت + برای باز کردن برنامه اثر انگشت لازم است + این دستگاه اثر انگشت ثبت‌شده ندارد + درباره + ورک‌ترک محصول لینومیک است — کابل، افغانستان. + نسخهٔ %1$s + تماس با پشتیبانی + diff --git a/firebase.demo.json b/firebase.demo.json new file mode 100644 index 0000000..a659b54 --- /dev/null +++ b/firebase.demo.json @@ -0,0 +1,51 @@ +{ + "functions": { + "source": "backend/functions", + "runtime": "nodejs22", + "predeploy": [ + "npm --prefix \"$RESOURCE_DIR\" run build" + ] + }, + "firestore": { + "rules": "backend/firestore.rules", + "indexes": "backend/firestore.indexes.json" + }, + "hosting": { + "public": "web/dist-demo", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], + "rewrites": [ + { + "source": "/v1/**", + "function": "api" + }, + { + "source": "**", + "destination": "/index.html" + } + ], + "headers": [ + { + "source": "**", + "headers": [ + { + "key": "Cache-Control", + "value": "no-cache, max-age=0, must-revalidate" + } + ] + }, + { + "source": "/assets/**", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + } + ] + } +} diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..4a8e1cb --- /dev/null +++ b/firebase.json @@ -0,0 +1,68 @@ +{ + "functions": { + "source": "backend/functions", + "runtime": "nodejs22", + "predeploy": [ + "npm --prefix \"$RESOURCE_DIR\" run build" + ] + }, + "firestore": { + "rules": "backend/firestore.rules", + "indexes": "backend/firestore.indexes.json" + }, + "hosting": { + "public": "web/dist", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], + "rewrites": [ + { + "source": "/v1/**", + "function": "api" + }, + { + "source": "**", + "destination": "/index.html" + } + ], + "headers": [ + { + "source": "**", + "headers": [ + { + "key": "Cache-Control", + "value": "no-cache, max-age=0, must-revalidate" + } + ] + }, + { + "source": "/assets/**", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + } + ] + }, + "emulators": { + "auth": { + "port": 9099 + }, + "functions": { + "port": 5001 + }, + "firestore": { + "port": 8080 + }, + "hosting": { + "port": 5000 + }, + "ui": { + "enabled": true + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..5a14591 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,9 @@ +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true + +android.useAndroidX=true +android.nonTransitiveRClass=true + +kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..93c1dfe --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,143 @@ +[versions] +agp = "8.13.0" +kotlin = "2.0.20" +ksp = "2.0.20-1.0.25" +coroutines = "1.9.0" +kotlinxSerialization = "1.7.3" + +androidxCore = "1.13.1" +androidxAppcompat = "1.7.0" +androidxLifecycle = "2.8.6" +androidxActivity = "1.9.2" +composeBom = "2024.09.03" +navigationCompose = "2.8.1" +hilt = "2.52" +hiltExt = "1.2.0" +room = "2.6.1" +work = "2.9.1" +datastore = "1.1.1" +biometric = "1.1.0" + +retrofit = "2.11.0" +okhttp = "4.12.0" +coil = "2.7.0" + +firebaseBom = "33.3.0" +googleServices = "4.4.2" +playServicesLocation = "21.3.0" +mlkitBarcode = "17.3.0" +mlkitFace = "16.1.7" +tflite = "2.17.0" +tfliteSupport = "0.5.0" +camerax = "1.4.2" + +javaxInject = "1" + +junit = "4.13.2" +turbine = "1.1.0" +mockk = "1.13.12" +androidxTestExt = "1.2.1" +androidxTestRunner = "1.6.2" + +[libraries] +# Kotlin / coroutines / serialization +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + +# AndroidX core +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } + +# Compose +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } + +# Hilt +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +hilt-ext-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltExt" } +hilt-ext-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" } +hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltExt" } + +# Room +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } + +# WorkManager / DataStore +androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" } + +# Network +retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" } +okhttp-core = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } + +# Images +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } + +# Firebase / Google Play services / ML Kit +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } +firebase-auth = { group = "com.google.firebase", name = "firebase-auth-ktx" } +play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } +mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" } +mlkit-face-detection = { group = "com.google.mlkit", name = "face-detection", version.ref = "mlkitFace" } +tflite = { group = "org.tensorflow", name = "tensorflow-lite", version.ref = "tflite" } +tflite-support = { group = "org.tensorflow", name = "tensorflow-lite-support", version.ref = "tfliteSupport" } +camerax-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" } +camerax-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } +camerax-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } +camerax-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } + +# Misc +javax-inject = { group = "javax.inject", name = "javax.inject", version.ref = "javaxInject" } + +# Testing +junit4 = { group = "junit", name = "junit", version.ref = "junit" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +androidx-test-ext = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } + +# Dependencies used by build-logic convention plugins +android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } +kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } +ksp-gradle-plugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } +compose-compiler-gradle-plugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } +room-gradle-plugin = { group = "androidx.room", name = "room-gradle-plugin", version.ref = "room" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +room = { id = "androidx.room", version.ref = "room" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } + +# Convention plugins exposed by build-logic +worktrack-android-application = { id = "worktrack.android.application" } +worktrack-android-library = { id = "worktrack.android.library" } +worktrack-android-library-compose = { id = "worktrack.android.library.compose" } +worktrack-android-feature = { id = "worktrack.android.feature" } +worktrack-android-hilt = { id = "worktrack.android.hilt" } +worktrack-android-room = { id = "worktrack.android.room" } +worktrack-jvm-library = { id = "worktrack.jvm.library" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..6b4c9cc --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=false +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..5467e13 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,11 @@ +# Generated by XcodeGen from project.yml — a pbxproj is a merge-conflict +# machine nobody can review. Run `xcodegen generate` after cloning. +*.xcodeproj +xcuserdata/ +DerivedData/ +.DS_Store + +# CocoaPods: the resolved pods are build inputs, like the APKs. Podfile and +# Podfile.lock ARE committed so the version is pinned. +Pods/ +*.xcworkspace diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..d20c627 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,38 @@ +# TensorFlow Lite for iOS is still CocoaPods-first (LiteRT's SPM support covers +# other components, not this one), so this one dependency comes through a +# Podfile while everything else stays XcodeGen. +# +# cd ios && xcodegen generate && pod install && open WorkTrack.xcworkspace +# +# The WORKSPACE is what you open from here on; the bare .xcodeproj no longer +# links the pod. +platform :ios, '16.0' +use_frameworks! + +target 'WorkTrack' do + # The same interpreter the Android app uses, so the same model file produces + # the same vector. Anything else — Vision's own face descriptors, a converted + # Core ML model — lands in a different space, and an employee enrolled on + # Android simply stops being recognised. + pod 'TensorFlowLiteSwift', '~> 2.17.0' + + target 'WorkTrackTests' do + inherit! :search_paths + end +end + +# TensorFlowLiteSwift ships a podspec targeting iOS 12, which current Xcode +# refuses outright: "the range of supported deployment target versions is 15.0 +# to 27.0". It only ever built because DerivedData had the result cached — a +# clean checkout, or CI, fails on the first build with an error that looks like +# it comes from our code and does not. +# +# Raising every pod to the app's own minimum fixes it and changes nothing else: +# 16.0 is already the floor the app itself is built against. +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' + end + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..28d7f83 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,26 @@ +PODS: + - TensorFlowLiteC (2.17.0): + - TensorFlowLiteC/Core (= 2.17.0) + - TensorFlowLiteC/Core (2.17.0) + - TensorFlowLiteSwift (2.17.0): + - TensorFlowLiteSwift/Core (= 2.17.0) + - TensorFlowLiteSwift/Core (2.17.0): + - TensorFlowLiteC (= 2.17.0) + - TensorFlowLiteSwift/Privacy (= 2.17.0) + - TensorFlowLiteSwift/Privacy (2.17.0) + +DEPENDENCIES: + - TensorFlowLiteSwift (~> 2.17.0) + +SPEC REPOS: + trunk: + - TensorFlowLiteC + - TensorFlowLiteSwift + +SPEC CHECKSUMS: + TensorFlowLiteC: eac6d689a8d391d1b202dc59e3d9165ba64aaf73 + TensorFlowLiteSwift: 723fe42222815e72490c79feeff05305530de393 + +PODFILE CHECKSUM: d2b89c955e33ce69ee248218888b68fe4fe11653 + +COCOAPODS: 1.17.0 diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..d5002f3 --- /dev/null +++ b/ios/README.md @@ -0,0 +1,95 @@ +# WorkTrack for iOS + +The employee app. Early — one screen works end to end. + +``` +cd ios +xcodegen generate # the .xcodeproj is generated, not committed +pod install # TensorFlow Lite, for the face model +open WorkTrack.xcworkspace # the WORKSPACE, not the project +``` + +`brew install cocoapods` if you do not have it — the system Ruby is too old for +the gem. + +Or from the command line: + +``` +xcodebuild -project WorkTrack.xcodeproj -scheme WorkTrack \ + -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test +``` + +(`-workspace WorkTrack.xcworkspace` rather than `-project`, now that there is a +pod.) + +## What works + +Five tabs — work, attendance, leave, pay, profile — with company notices under the day's work. Sign in, and "your work" — today and the next working day, with the project, +the place, who else is on the job, and reporting progress on your own task. It +talks to the live demo backend (`Backend.current` in `Core/Environment.swift`). + +## What is deliberate + +**No signing.** There is no Apple Developer ID, so this runs in the Simulator +only. That is not a blocker for building it — see `docs/15-ios-app.md` for why +distribution, not the code, is the hard part for this app. + +**TensorFlow Lite is the one pod.** Face embedding has to use the SAME model +and the SAME interpreter as Android — `mobilefacenet.tflite` is copied from +`feature/attendance/src/main/assets/`. Vision's own face descriptors, or a +Core ML conversion, would land in a different vector space, and the server +compares against whatever the *enrolling* phone produced. Core TFLite for iOS +is still CocoaPods-first, so that is why a Podfile exists at all. + +**The icon is generated, not drawn.** `make-appicon.py` renders the same clock +mark the Android launcher uses, on the same #006874, so the two apps are one +product on a desk with both phones on it. Regenerate rather than editing a PNG. + +**No Firebase SDK.** Sign-in and token refresh are two POSTs +(`Auth/FirebaseAuthREST.swift`), so the project stays buildable from a +checked-in spec with no package resolution. The SDK arrives if push +notifications or Firestore do. + +**No `X-Device-Id`.** The licence counts phones running the *Android* app. The +server treats a missing header as "not a licensed device", so sending an +invented id here would quietly eat a customer's seats. + +**Foundation's Persian calendar**, not a port of the Kotlin one — checked +against `web/src/shamsi/solarHijri.ts` across Nowruz and a leap day before it +was relied on. Only the month names are ours: Afghanistan says حمل where Iran +says فروردین, and a test holds that line. + +## Next + +**The cross-platform face test, on real devices.** Everything about the face +pipeline is verified on this side — the preprocessing contract, the crop, the +model loading, determinism — but the one test that actually matters cannot be +run in a Simulator, which has no camera: + +> Enrol on an Android phone. Verify the same person on an iPhone. The +> similarity the server reports must be well above the 0.6 threshold — aim for +> 0.8+. Anything near the line means the preprocessing differs somewhere. + +Until that has been done with a real face on two real handsets, treat face +check-in on iOS as unverified. It will not error if it is wrong; it will just +stop recognising people. + +The camera screen itself is built: live front-camera preview, a face guide, +enrol-or-verify from the same screen, and the server's two-step handshake +carried into the punch. It refuses to run where there is no camera — including +every Simulator — rather than falling back to the photo library, because +choosing an existing image would let one worker check another in by +photographing a photograph. + +Push notifications are the obvious next thing and are BLOCKED, not skipped: +they do not exist anywhere in this product yet — not on the backend, not on +Android — and on iOS they additionally need APNs keys from the same Apple +Developer account that gates everything else. Building the iOS half alone +would be a client with nothing to receive. + +What is left that is not blocked: QR/kiosk check-in, which Android has and +this does not. + +Also worth knowing during development: an ad-hoc signed rebuild can invalidate +the Keychain item, so a reinstall sometimes asks for the password again. On a +properly signed build the identity is stable and it does not happen. diff --git a/ios/WorkTrack/Announcements/Announcements.swift b/ios/WorkTrack/Announcements/Announcements.swift new file mode 100644 index 0000000..4dbba18 --- /dev/null +++ b/ios/WorkTrack/Announcements/Announcements.swift @@ -0,0 +1,87 @@ +import Foundation + +enum AnnouncementPriority: String, Codable { + case normal = "NORMAL" + case important = "IMPORTANT" + case urgent = "URGENT" + + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + // An unknown priority is NORMAL, never URGENT: a server that grows a + // fourth level must not start shouting at everybody. + self = AnnouncementPriority(rawValue: raw) ?? .normal + } + + var label: String { + switch self { + case .normal: return "" + case .important: return L.t("ann_important") + case .urgent: return L.t("ann_urgent") + } + } +} + +/// A notice from the company. +struct Announcement: Codable, Identifiable, Equatable { + let id: String + let title: String + let body: String + let priority: AnnouncementPriority + let publishedAt: String + let expiresAt: String? + let createdByName: String? +} + +/// What the company is telling everybody. +@MainActor +final class AnnouncementsViewModel: ObservableObject { + enum State: Equatable { + case loading + case loaded([Announcement]) + case failed(String) + } + + @Published private(set) var state: State = .loading + /// How many the worker has not opened yet, for the tab badge. + @Published private(set) var unreadCount = 0 + + private let client: ApiClient + private let store: OfflineStore + private let readFile = "announcements-read" + + init(client: ApiClient, store: OfflineStore = OfflineStore()) { + self.client = client + self.store = store + } + + func load() async { + do { + let items: [Announcement] = try await client.get("announcements") + // Newest first. The server already filters out unpublished and + // expired ones, so what arrives is what should be shown. + let sorted = items.sorted { $0.publishedAt > $1.publishedAt } + state = .loaded(sorted) + recount(sorted) + } catch ApiError.offline { + state = .failed(L.t("err_offline")) + } catch { + state = .failed(L.t("err_generic")) + } + } + + /// Marks everything currently listed as seen. + /// + /// Read state is per-device and stays on the phone: the server has no + /// notion of it, and inventing one would mean writing to the tenant every + /// time somebody opens a tab. + func markAllRead() { + guard case .loaded(let items) = state else { return } + store.save(items.map(\.id), to: readFile) + unreadCount = 0 + } + + private func recount(_ items: [Announcement]) { + let seen = Set(store.load([String].self, from: readFile) ?? []) + unreadCount = items.filter { !seen.contains($0.id) }.count + } +} diff --git a/ios/WorkTrack/Announcements/AnnouncementsView.swift b/ios/WorkTrack/Announcements/AnnouncementsView.swift new file mode 100644 index 0000000..f0aae1f --- /dev/null +++ b/ios/WorkTrack/Announcements/AnnouncementsView.swift @@ -0,0 +1,109 @@ +import SwiftUI + +/// Company notices. +struct AnnouncementsView: View { + @EnvironmentObject private var app: AppState + @StateObject private var model: AnnouncementsViewModel + + init(client: ApiClient) { + _model = StateObject(wrappedValue: AnnouncementsViewModel(client: client)) + } + + var body: some View { + NavigationStack { + Group { + switch model.state { + case .loading: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed(let message): + RetryState(message: message) { Task { await model.load() } } + case .loaded(let items): + if items.isEmpty { + RetryState(message: L.t("ann_none")) { Task { await model.load() } } + } else { + List(items) { announcement in + row(announcement) + } + .listStyle(.insetGrouped) + .refreshable { await model.load() } + } + } + } + .navigationTitle(L.t("ann_title")) + } + .task { + await model.load() + // Opening the tab IS reading them; a separate "mark read" would be + // a chore nobody performs. + model.markAllRead() + } + .badge(model.unreadCount) + } + + private func row(_ announcement: Announcement) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top) { + Text(announcement.title).font(.headline) + Spacer() + if announcement.priority != .normal { + Pill( + text: announcement.priority.label, + tone: announcement.priority == .urgent ? Palette.negative : Palette.warning + ) + } + } + Text(announcement.body).font(.subheadline) + + HStack(spacing: 6) { + if let date = published(announcement) { + Text(AfghanCalendar.format(date, language: app.language)) + } + if let author = announcement.createdByName, !author.isEmpty { + Text("·") + Text(author) + } + } + .font(.caption2).foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + + private func published(_ announcement: Announcement) -> Date? { + let parser = ISO8601DateFormatter() + parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return parser.date(from: announcement.publishedAt) + ?? ISO8601DateFormatter().date(from: announcement.publishedAt) + } +} + + +/// The full list, pushed from the work screen when there are more than fit. +struct AnnouncementsList: View { + let items: [Announcement] + @EnvironmentObject private var app: AppState + + var body: some View { + List(items) { announcement in + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top) { + Text(announcement.title).font(.headline) + Spacer() + if announcement.priority != .normal { + Pill( + text: announcement.priority.label, + tone: announcement.priority == .urgent + ? Palette.negative : Palette.warning + ) + } + } + Text(announcement.body).font(.subheadline) + if let author = announcement.createdByName, !author.isEmpty { + Text(author).font(.caption2).foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + .navigationTitle(L.t("ann_title")) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/ios/WorkTrack/App/AppState.swift b/ios/WorkTrack/App/AppState.swift new file mode 100644 index 0000000..9ccd024 --- /dev/null +++ b/ios/WorkTrack/App/AppState.swift @@ -0,0 +1,30 @@ +import SwiftUI + +/// App-wide preferences. Currently just the language, which drives both the +/// strings and the layout direction. +@MainActor +final class AppState: ObservableObject { + @Published private(set) var language: Language + /// Lives here because a language change rebuilds the tab view. + @Published var tab: AppTab = .work + + private static let key = "worktrack.language" + + init() { + let stored = UserDefaults.standard.string(forKey: Self.key) + // Dari is the default, not the phone's language: an Afghan user with an + // English handset should still open a Dari app. + language = stored.flatMap(Language.init(rawValue:)) ?? .dari + L.language = language + } + + func setLanguage(_ next: Language) { + language = next + L.language = next + UserDefaults.standard.set(next.rawValue, forKey: Self.key) + } +} + +enum AppTab: Hashable { + case work, attendance, leave, pay, profile +} diff --git a/ios/WorkTrack/App/Theme.swift b/ios/WorkTrack/App/Theme.swift new file mode 100644 index 0000000..dab3312 --- /dev/null +++ b/ios/WorkTrack/App/Theme.swift @@ -0,0 +1,39 @@ +import SwiftUI + +/// The palette, from the brand: linumic orange on a deep teal-ink ground. +enum Palette { + static let accent = Color(red: 1.0, green: 0.427, blue: 0.255) // #FF6D41 + static let ink = Color(red: 0.039, green: 0.153, blue: 0.208) // #0A2735 + static let deep = Color(red: 0.0, green: 0.306, blue: 0.447) // #004E72 + static let surface = Color(red: 0.976, green: 0.976, blue: 0.976) // #F9F9F9 + + static let positive = Color(red: 0.18, green: 0.49, blue: 0.20) + static let warning = Color(red: 0.72, green: 0.47, blue: 0.0) + static let negative = Color(red: 0.70, green: 0.15, blue: 0.12) + static let neutral = Color.secondary +} + +extension TaskStatus { + var tone: Color { + switch self { + case .planned: return Palette.neutral + case .inProgress: return Palette.warning + case .done: return Palette.positive + case .blocked: return Palette.negative + } + } +} + +/// A small pill, the same idea as the portal's `Chip`. +struct Pill: View { + let text: String + var tone: Color = Palette.neutral + + var body: some View { + Text(text) + .font(.caption).fontWeight(.medium) + .foregroundStyle(tone) + .padding(.horizontal, 10).padding(.vertical, 4) + .background(tone.opacity(0.12), in: Capsule()) + } +} diff --git a/ios/WorkTrack/App/WorkTrackApp.swift b/ios/WorkTrack/App/WorkTrackApp.swift new file mode 100644 index 0000000..33d5c44 --- /dev/null +++ b/ios/WorkTrack/App/WorkTrackApp.swift @@ -0,0 +1,121 @@ +import SwiftUI + +@main +struct WorkTrackApp: App { + @StateObject private var app = AppState() + @StateObject private var auth = AuthStore() + @StateObject private var lock = AppLock() + @Environment(\.scenePhase) private var scenePhase + + var body: some Scene { + WindowGroup { + RootView(lock: lock) + .environmentObject(app) + .environmentObject(auth) + .onChange(of: scenePhase) { phase in + // Re-lock when the app leaves the screen, not when it + // returns: locking on return would leave the contents + // visible in the app switcher, which is where a shared + // phone gets read over somebody's shoulder. + if phase == .background { lock.lockIfNeeded() } + // Coming forward is the moment to ask whether anything + // about this person changed while the app was away — a + // module his company switched on, or an account that has + // since been closed. It used to be asked only at launch, + // so a manager could enable face check-in, tell him to + // look, and nothing would happen. + // + // At launch this fires against `state == .loading` while + // `start()` is still running, and refreshMe() steps aside + // for that. + if phase == .active { Task { await auth.refreshMe() } } + } + // Dari and Pashto are right-to-left, and the whole layout has + // to follow — not just the text. Driven by the app's own + // language rather than the phone's. + .environment(\.layoutDirection, app.language.isRTL ? .rightToLeft : .leftToRight) + // Re-render every string when the language changes; L is a + // plain lookup, so it needs the nudge. + .id(app.language) + } + } +} + +struct RootView: View { + @EnvironmentObject private var auth: AuthStore + @EnvironmentObject private var app: AppState + @ObservedObject var lock: AppLock + + var body: some View { + Group { + switch auth.state { + case .loading: + ProgressView() + case .signedOut: + // The next person starts on Work, not the last one's tab. + SignInView().onAppear { app.tab = .work } + case .signedIn: + // The lock sits OVER a live session. It gates who may look, + // not whether the session survives — signing out instead would + // discard queued punches over a privacy setting. + if lock.isLocked { + AppLockScreen(lock: lock) + } else { + SignedInTabs(client: auth.client, lock: lock) + } + } + } + .task { await auth.start() } + } +} + +/// What an employee comes here for. Nothing a manager does is in this app. +/// +/// The attendance model is built HERE rather than inside the work tab, because +/// the profile screen shows the same queue: two instances would each hold their +/// own copy of what the phone is waiting to send, and the profile would report +/// "everything sent" while a punch sat in the other one. +struct SignedInTabs: View { + let client: ApiClient + @EnvironmentObject private var app: AppState + @ObservedObject var lock: AppLock + + @StateObject private var location = LocationProvider() + @StateObject private var attendance: AttendanceViewModel + private let cache: WorkCache + + init(client: ApiClient, lock: AppLock) { + self.client = client + self.lock = lock + let cache = WorkCache() + self.cache = cache + let provider = LocationProvider() + _location = StateObject(wrappedValue: provider) + _attendance = StateObject( + wrappedValue: AttendanceViewModel(client: client, location: provider, cache: cache) + ) + } + + var body: some View { + TabView(selection: $app.tab) { + MyWorkView(client: client, attendance: attendance, cache: cache) + // A checklist, not a hammer. This is an office product that + // happens to be used on sites; a tool icon narrows it to + // manual trades and reads wrong to every other customer. + .tabItem { Label(L.t("tab_work"), systemImage: "checklist") } + .tag(AppTab.work) + AttendanceHistoryView(client: client) + .tabItem { Label(L.t("tab_history"), systemImage: "clock.fill") } + .tag(AppTab.attendance) + LeaveView(client: client) + .tabItem { Label(L.t("tab_leave"), systemImage: "calendar") } + .tag(AppTab.leave) + PayslipsView(client: client) + .tabItem { Label(L.t("tab_pay"), systemImage: "doc.text.fill") } + .tag(AppTab.pay) + ProfileView(attendance: attendance, lock: lock) + .tabItem { Label(L.t("tab_profile"), systemImage: "person.crop.circle.fill") } + .tag(AppTab.profile) + } + } +} diff --git a/ios/WorkTrack/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/WorkTrack/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..8833eb5 --- /dev/null +++ b/ios/WorkTrack/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,6 @@ +{ + "images": [ + { "filename": "icon-1024.png", "idiom": "universal", "platform": "ios", "size": "1024x1024" } + ], + "info": { "author": "xcode", "version": 1 } +} diff --git a/ios/WorkTrack/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios/WorkTrack/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000..9791722 Binary files /dev/null and b/ios/WorkTrack/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/ios/WorkTrack/Assets.xcassets/Contents.json b/ios/WorkTrack/Assets.xcassets/Contents.json new file mode 100644 index 0000000..ac502da --- /dev/null +++ b/ios/WorkTrack/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{ "info": { "author": "xcode", "version": 1 } } diff --git a/ios/WorkTrack/Attendance/AttendanceHistoryView.swift b/ios/WorkTrack/Attendance/AttendanceHistoryView.swift new file mode 100644 index 0000000..5e26e3c --- /dev/null +++ b/ios/WorkTrack/Attendance/AttendanceHistoryView.swift @@ -0,0 +1,222 @@ +import SwiftUI + +/// The worker's own attendance, day by day, and the way to say a day is wrong. +/// +/// History and corrections are one screen on purpose: nobody asks for a +/// correction in the abstract — they look at a day, see it is wrong, and say +/// so. Two screens would make them carry the date in their head. +struct AttendanceHistoryView: View { + @EnvironmentObject private var app: AppState + @StateObject private var model: AttendanceHistoryViewModel + @State private var correcting: AttendanceDay? + + init(client: ApiClient) { + _model = StateObject(wrappedValue: AttendanceHistoryViewModel(client: client)) + } + + var body: some View { + NavigationStack { + Group { + switch model.state { + case .loading: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed(let message): + RetryState(message: message) { Task { await model.load() } } + case .loaded(let overview): + if overview.days.isEmpty { + RetryState(message: L.t("hist_none")) { Task { await model.load() } } + } else { + List(overview.days, id: \.date) { day in + row(day, pending: overview.pendingCorrection(on: day.date)) + } + .listStyle(.insetGrouped) + .refreshable { await model.load() } + } + } + } + .navigationTitle(L.t("hist_title")) + .sheet(item: $correcting) { day in + CorrectionRequestView(model: model, day: day) + } + } + .task { await model.load() } + } + + private func row(_ day: AttendanceDay, pending: Regularization?) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + VStack(alignment: .leading, spacing: 2) { + if let date = AfghanCalendar.parseISODate(day.date) { + Text(AfghanCalendar.format(date, language: app.language, withYear: false)) + .font(.headline) + } + HStack(spacing: 6) { + Text(clock(day.firstInAt) ?? "—") + Text("←") + Text(clock(day.lastOutAt) ?? "—") + } + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Pill( + text: (day.status ?? .pending).label, + tone: statusTone(day.status ?? .pending) + ) + } + + if let pending { + // Say it is already asked for, rather than offering the button + // again and letting them file a second one. + Label(L.t("hist_correction_pending"), systemImage: "clock.arrow.circlepath") + .font(.caption).foregroundStyle(Palette.warning) + if !pending.reason.isEmpty { + Text(pending.reason).font(.caption2).foregroundStyle(.secondary) + } + } else { + Button(L.t("hist_request_correction")) { correcting = day } + .font(.caption).foregroundStyle(Palette.deep) + } + } + .padding(.vertical, 4) + } + + private func statusTone(_ status: AttendanceDayStatus) -> Color { + switch status { + case .present: return Palette.positive + // Absent is the one worth colouring: it is the day somebody comes to + // ask about, and it is the one that costs them pay. + case .absent: return Palette.negative + case .halfDay: return Palette.warning + default: return Palette.neutral + } + } + + private func clock(_ iso: String?) -> String? { + guard let iso else { return nil } + let parser = ISO8601DateFormatter() + parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let date = parser.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) else { + return nil + } + let out = DateFormatter() + out.dateFormat = "HH:mm" + out.timeZone = TimeZone(identifier: "Asia/Kabul") + return L.n(out.string(from: date)) + } +} + +extension AttendanceDay: Identifiable { + public var id: String { date } +} + +/// Asking for one day to be corrected. +struct CorrectionRequestView: View { + @ObservedObject var model: AttendanceHistoryViewModel + let day: AttendanceDay + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var app: AppState + @State private var fixIn = false + @State private var fixOut = false + @State private var inAt = Date() + @State private var outAt = Date() + @State private var reason = "" + + var body: some View { + NavigationStack { + Form { + Section { + // Each time is opt-in. Somebody who forgot to check OUT + // should not have to restate when they arrived — and a + // restated time that differs slightly would look like a + // second thing to approve. + Toggle(L.t("hist_fix_in"), isOn: $fixIn) + if fixIn { + DatePicker(L.t("hist_in_time"), selection: $inAt, + displayedComponents: .hourAndMinute) + } + Toggle(L.t("hist_fix_out"), isOn: $fixOut) + if fixOut { + DatePicker(L.t("hist_out_time"), selection: $outAt, + displayedComponents: .hourAndMinute) + } + } header: { + if let date = AfghanCalendar.parseISODate(day.date) { + Text(AfghanCalendar.format(date, language: app.language)) + } + } footer: { + Text(L.t("hist_correction_note")) + } + + Section(L.t("leave_reason")) { + TextField(L.t("hist_reason_hint"), text: $reason, axis: .vertical) + .lineLimit(3...6) + } + + if let error = model.submitError { + Section { Text(error).foregroundStyle(Palette.negative).font(.subheadline) } + } + } + .navigationTitle(L.t("hist_request_correction")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L.t("common_cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(L.t("leave_send")) { + Task { + if await model.requestCorrection( + date: day.date, + inAt: fixIn ? Self.combine(day.date, inAt) : nil, + outAt: fixOut ? Self.combine(day.date, outAt) : nil, + reason: reason.trimmingCharacters(in: .whitespacesAndNewlines) + ) { dismiss() } + } + } + .fontWeight(.semibold) + .disabled(!canSubmit) + } + } + } + } + + private var canSubmit: Bool { + // At least one time, or there is nothing to correct. + (fixIn || fixOut) + && !reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !model.isSubmitting + } + + /// The wall-clock time the worker picked, on the day being corrected, read + /// as the company's local time. + /// + /// Two timezones meet here and mixing them up is silent. The picker shows + /// and returns the DEVICE's wall clock, so "2:02 PM" must be read in the + /// device's zone — that is the number the person actually saw. What they + /// MEAN by it is 2:02 PM at the site, so those digits are then placed on + /// the corrected day in Kabul. + /// + /// Reading the components in Kabul instead silently shifts the request by + /// the offset between the handset and the site: a phone still set to + /// Toronto turned a 2:02 PM correction into 22:32, and nothing anywhere + /// would have said so. + static func combine(_ date: String, _ time: Date, deviceZone: TimeZone = .current) -> Date? { + guard let site = TimeZone(identifier: "Asia/Kabul"), + let day = AfghanCalendar.parseISODate(date) else { return nil } + + var asDisplayed = Calendar(identifier: .gregorian) + asDisplayed.timeZone = deviceZone + let clock = asDisplayed.dateComponents([.hour, .minute], from: time) + + var atSite = Calendar(identifier: .gregorian) + atSite.timeZone = site + // The DATE comes from the day being corrected, which is already a plain + // calendar date, so it is read in the site's zone like everything else. + var parts = atSite.dateComponents([.year, .month, .day], from: day) + parts.hour = clock.hour + parts.minute = clock.minute + parts.timeZone = site + return atSite.date(from: parts) + } +} diff --git a/ios/WorkTrack/Attendance/AttendanceHistoryViewModel.swift b/ios/WorkTrack/Attendance/AttendanceHistoryViewModel.swift new file mode 100644 index 0000000..3f76002 --- /dev/null +++ b/ios/WorkTrack/Attendance/AttendanceHistoryViewModel.swift @@ -0,0 +1,105 @@ +import Foundation + +/// The worker's own attendance, and the corrections asked for on it. +@MainActor +final class AttendanceHistoryViewModel: ObservableObject { + struct Overview: Equatable { + let days: [AttendanceDay] + let corrections: [Regularization] + + /// A day already has a pending correction — asking twice is confusing + /// for everyone, and the server would file a second one. + func pendingCorrection(on date: String) -> Regularization? { + corrections.first { $0.date == date && $0.status == .pending } + } + } + + enum State: Equatable { + case loading + case loaded(Overview) + case failed(String) + } + + @Published private(set) var state: State = .loading + @Published private(set) var isSubmitting = false + @Published var submitError: String? + + /// How far back the list goes. A month covers the pay period somebody is + /// actually querying; older than that and the payroll run has closed. + private static let daysBack = 30 + + private let client: ApiClient + + init(client: ApiClient) { + self.client = client + } + + func load() async { + let to = Self.isoDate(Date()) + let from = Self.isoDate(Date().addingTimeInterval(-Double(Self.daysBack) * 86_400)) + do { + async let days: [AttendanceDay] = client.get( + "attendance/days", query: ["from": from, "to": to] + ) + async let corrections: [Regularization] = client.get( + "attendance/regularizations", query: ["scope": "mine"] + ) + state = .loaded(Overview( + // Newest first: a correction is almost always about yesterday. + days: try await days.sorted { $0.date > $1.date }, + corrections: try await corrections + )) + } catch ApiError.offline { + state = .failed(L.t("err_offline")) + } catch { + state = .failed(L.t("err_generic")) + } + } + + /// File a correction. Online only: a request nobody can see is not a + /// request, and the approver is the point of it. + func requestCorrection( + date: String, inAt: Date?, outAt: Date?, reason: String + ) async -> Bool { + isSubmitting = true + submitError = nil + defer { isSubmitting = false } + + var body: [String: Any] = [ + "id": ULID.generate(), + "date": date, + "reason": reason, + ] + // Both are optional and either may be the one that is wrong — somebody + // who forgot to check OUT should not have to restate when they came in. + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime] + if let inAt { body["requestedInAt"] = iso.string(from: inAt) } + if let outAt { body["requestedOutAt"] = iso.string(from: outAt) } + + do { + let _: Regularization = try await client.post( + "attendance/regularizations", body: body + ) + await load() + return true + } catch ApiError.offline { + submitError = L.t("err_offline") + } catch ApiError.problem(_, _, let detail) { + submitError = detail + } catch { + submitError = L.t("err_generic") + } + return false + } + + /// The company's day, not the phone's. + static func isoDate(_ date: Date) -> String { + let f = DateFormatter() + f.calendar = Calendar(identifier: .gregorian) + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "Asia/Kabul") + f.dateFormat = "yyyy-MM-dd" + return f.string(from: date) + } +} diff --git a/ios/WorkTrack/Attendance/AttendanceModels.swift b/ios/WorkTrack/Attendance/AttendanceModels.swift new file mode 100644 index 0000000..2b0673e --- /dev/null +++ b/ios/WorkTrack/Attendance/AttendanceModels.swift @@ -0,0 +1,86 @@ +import Foundation + +enum PunchType: String { case inbound = "IN", outbound = "OUT" } + +/// A fence the company drew around a site. +struct Geofence: Codable, Identifiable, Equatable { + let id: String + let name: String? + let latitude: Double + let longitude: Double + let radiusMeters: Double + let active: Bool? + + var isActive: Bool { active ?? true } +} + +/// What came back from a punch. `serverValidated` is the field that matters: +/// the server records a punch made outside the fence rather than refusing it, +/// and flags it — so the app must say so rather than showing a plain success. +struct PunchResult: Decodable, Equatable { + let id: String + let type: String + let punchedAt: String + let serverValidated: Bool? + let invalidReason: String? + let insideFence: Bool? + + var wasAccepted: Bool { serverValidated ?? true } +} + +/// How the server classified a day. +/// +/// The exact set the rest of the product uses — AttendanceDayStatus in +/// core/model/Attendance.kt. Spelling one of these by hand is how "WEEK_OFF" +/// ended up rendering raw on screen next to properly translated neighbours: +/// I had guessed "WEEKEND". +enum AttendanceDayStatus: String, Codable { + case present = "PRESENT" + case absent = "ABSENT" + case halfDay = "HALF_DAY" + case leave = "LEAVE" + case holiday = "HOLIDAY" + case weekOff = "WEEK_OFF" + /// The day is not settled yet — punches are in but the projection has not + /// been recomputed. + case pending = "PENDING" + + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = AttendanceDayStatus(rawValue: raw) ?? .pending + } + + var label: String { + switch self { + case .present: return L.t("hist_present") + case .absent: return L.t("hist_absent") + case .halfDay: return L.t("hist_half_day") + case .leave: return L.t("hist_on_leave") + case .holiday: return L.t("hist_holiday") + case .weekOff: return L.t("hist_weekend") + case .pending: return L.t("hist_pending") + } + } + + /// Days the company never expected anybody in. A correction on one of + /// these is still allowed — people do work on their day off — but it is + /// not what the screen leads with. + var isNonWorking: Bool { self == .weekOff || self == .holiday } +} + +/// One day's attendance projection, as the server computes it. +struct AttendanceDay: Codable, Equatable { + let date: String + let status: AttendanceDayStatus? + let firstInAt: String? + let lastOutAt: String? + let workedMinutes: Int? + + /// In if the day has an opening punch and no closing one after it. + var isClockedIn: Bool { firstInAt != nil && lastOutAt == nil } +} + +/// The envelope /v1/sync/pull returns. +struct SyncPage: Decodable { + let items: [T] +} diff --git a/ios/WorkTrack/Attendance/AttendanceViewModel.swift b/ios/WorkTrack/Attendance/AttendanceViewModel.swift new file mode 100644 index 0000000..5463c10 --- /dev/null +++ b/ios/WorkTrack/Attendance/AttendanceViewModel.swift @@ -0,0 +1,203 @@ +import CoreLocation +import Foundation + +/// Punching in and out, with or without signal. +@MainActor +final class AttendanceViewModel: ObservableObject { + enum Outcome: Equatable { + /// Reached the server and was inside the site (or no fences exist). + case accepted(PunchType) + /// Reached the server, which flagged it — almost always because the + /// worker was outside the fence. Deliberately not an error: the punch + /// exists, and a manager sees it and can correct the day. + case flagged(reason: String) + /// Saved on the phone. Not a failure — the punch is recorded with the + /// time it happened and will go when there is signal. + case queued(PunchType) + /// Queued so long the server will no longer accept it. + case expired(count: Int) + case failed(String) + } + + @Published private(set) var today: AttendanceDay? + @Published private(set) var isPunching = false + @Published private(set) var evaluation: GeofenceEvaluator.Evaluation? + @Published private(set) var isStale = false + @Published var outcome: Outcome? + + let outbox: PunchOutbox + + private let client: ApiClient + private let location: LocationProvider + private let cache: WorkCache + private var fences: [Geofence] = [] + + init( + client: ApiClient, + location: LocationProvider, + // Defaults are built here rather than in the signature: PunchOutbox is + // @MainActor because it publishes to the UI, and a default argument is + // evaluated outside that isolation. + outbox: PunchOutbox? = nil, + cache: WorkCache = WorkCache() + ) { + self.client = client + self.location = location + self.outbox = outbox ?? PunchOutbox() + self.cache = cache + // Show something immediately, before any request: a site with no signal + // is the normal case, not the exception. + if let cached = cache.load() { + today = cached.attendance + fences = cached.fences + isStale = true + } + } + + var pendingCount: Int { outbox.pending.count } + + func load(todayISO: String, work: MyWork? = nil) async { + let days: [AttendanceDay]? = try? await client.get( + "attendance/days", query: ["from": todayISO, "to": todayISO] + ) + let page: SyncPage? = try? await client.get( + "sync/pull", query: ["type": "geofences"] + ) + + // A failed fetch must not wipe what we already had — that would trade a + // stale answer for no answer, which is worse. + if let days { + today = days.first + isStale = false + } + if let page { fences = page.items } + if days != nil || page != nil { + cache.save(work: work ?? cache.load()?.work, attendance: today, fences: fences) + } + } + + /// Punch. The type comes from the day the SERVER computed where possible, + /// so a manager's correction or a second device cannot leave the app + /// offering "check in" to somebody already in. + /// Punch, optionally carrying proof that a face was verified first. + func punch(todayISO: String, faceToken: String? = nil) async { + isPunching = true + outcome = nil + defer { isPunching = false } + + let type: PunchType = isClockedIn ? .outbound : .inbound + + let fix: CLLocation + do { + fix = try await location.currentLocation() + } catch LocationProvider.Failure.denied { + outcome = .failed(L.t("err_location_denied")) + return + } catch { + outcome = .failed(L.t("err_location_unavailable")) + return + } + + let accuracy = max(fix.horizontalAccuracy, 0) + let local = GeofenceEvaluator.evaluate( + latitude: fix.coordinate.latitude, + longitude: fix.coordinate.longitude, + accuracyMeters: accuracy, + fences: fences + ) + evaluation = local + + let queued = QueuedPunch( + id: ULID.generate(), + // The moment he punched, not the moment it is sent. + punchedAt: Date(), + type: type.rawValue, + latitude: fix.coordinate.latitude, + longitude: fix.coordinate.longitude, + accuracyMeters: accuracy, + insideFence: local.insideFence, + faceToken: faceToken + ) + + do { + let result = try await send(queued) + outcome = result.wasAccepted + ? .accepted(type) + : .flagged(reason: result.invalidReason ?? "") + await load(todayISO: todayISO) + } catch ApiError.offline { + // Not a failure. Keep it and say so. + outbox.enqueue(queued) + applyLocally(queued) + outcome = .queued(type) + } catch ApiError.problem(_, _, let detail) { + outcome = .failed(detail) + } catch { + outcome = .failed(L.t("err_generic")) + } + } + + /// Sends everything the queue is holding. Safe to call at any time: each + /// punch carries its own id, so a send that already landed is a no-op. + func drain(todayISO: String) async { + let expired = outbox.discardExpired() + if !expired.isEmpty { outcome = .expired(count: expired.count) } + guard !outbox.pending.isEmpty else { return } + + for punch in outbox.pending { + do { + _ = try await send(punch) + outbox.remove(id: punch.id) + } catch ApiError.offline { + return // still no signal; the rest stay queued + } catch { + // A business rejection will not become acceptable by repeating + // it. Drop it so the queue drains, and let the day's record — + // which the server owns — be the truth. + outbox.remove(id: punch.id) + } + } + await load(todayISO: todayISO) + } + + private func send(_ punch: QueuedPunch) async throws -> PunchResult { + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime] + return try await client.post( + "attendance/punches", + body: [ + "id": punch.id, + "punchedAt": iso.string(from: punch.punchedAt), + "type": punch.type, + "method": punch.method, + "latitude": punch.latitude, + "longitude": punch.longitude, + "accuracyMeters": punch.accuracyMeters, + "insideFence": punch.insideFence, + ].merging(punch.faceToken.map { ["faceToken": $0] } ?? [:]) { current, _ in current } + ) + } + + /// Whether the worker is in, counting punches the server has not seen yet — + /// otherwise checking in offline would leave the button still saying + /// "check in". + var isClockedIn: Bool { + if let last = outbox.pending.last { return last.type == PunchType.inbound.rawValue } + return today?.isClockedIn ?? false + } + + /// Reflects a queued punch in the visible day so the card is not lying + /// while the queue waits. + private func applyLocally(_ punch: QueuedPunch) { + guard punch.type == PunchType.inbound.rawValue else { return } + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime] + today = AttendanceDay( + date: today?.date ?? "", + status: .present, + firstInAt: today?.firstInAt ?? iso.string(from: punch.punchedAt), + lastOutAt: nil, + workedMinutes: today?.workedMinutes ?? 0 + ) + } +} diff --git a/ios/WorkTrack/Attendance/GeofenceEvaluator.swift b/ios/WorkTrack/Attendance/GeofenceEvaluator.swift new file mode 100644 index 0000000..d13c7f0 --- /dev/null +++ b/ios/WorkTrack/Attendance/GeofenceEvaluator.swift @@ -0,0 +1,74 @@ +import Foundation + +/// Where the worker is, relative to the company's sites. +/// +/// The SERVER decides whether a punch counts — `checkGeofence` in +/// backend/functions/src/services/geo.ts never trusts a client's claim. This +/// exists only so the app can tell somebody they are 300 metres from the gate +/// BEFORE they punch, instead of after. +/// +/// It therefore has to agree with the server, and mirrors it exactly: distance +/// by haversine, GPS accuracy credited toward the radius, and inside ANY fence +/// counts — not merely the nearest one, because a compound and a building +/// inside it are both normally mapped and the smaller one can be nearer. +enum GeofenceEvaluator { + struct Evaluation: Equatable { + /// False when the company drew no fences: then anywhere is fine. + let fencesConfigured: Bool + let insideFence: Bool + let nearest: Geofence? + let distanceMeters: Double? + } + + private static let earthRadiusMeters = 6_371_000.0 + + static func haversineMeters( + _ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double + ) -> Double { + let toRad = { (d: Double) in d * .pi / 180 } + let dLat = toRad(lat2 - lat1) + let dLng = toRad(lng2 - lng1) + let a = pow(sin(dLat / 2), 2) + + cos(toRad(lat1)) * cos(toRad(lat2)) * pow(sin(dLng / 2), 2) + return 2 * earthRadiusMeters * atan2(sqrt(a), sqrt(1 - a)) + } + + static func evaluate( + latitude: Double, + longitude: Double, + accuracyMeters: Double, + fences: [Geofence] + ) -> Evaluation { + let active = fences.filter(\.isActive) + guard !active.isEmpty else { + return Evaluation( + fencesConfigured: false, insideFence: false, nearest: nil, distanceMeters: nil + ) + } + + var nearest: Geofence? + var nearestDistance = Double.infinity + var inside: Geofence? + var insideDistance = Double.infinity + + for fence in active { + let distance = haversineMeters(latitude, longitude, fence.latitude, fence.longitude) + if distance < nearestDistance { + nearestDistance = distance + nearest = fence + } + if distance - accuracyMeters <= fence.radiusMeters && distance < insideDistance { + insideDistance = distance + inside = fence + } + } + + let isInside = inside != nil + return Evaluation( + fencesConfigured: true, + insideFence: isInside, + nearest: isInside ? inside : nearest, + distanceMeters: (isInside ? insideDistance : nearestDistance).rounded() + ) + } +} diff --git a/ios/WorkTrack/Attendance/LocationProvider.swift b/ios/WorkTrack/Attendance/LocationProvider.swift new file mode 100644 index 0000000..7f0f976 --- /dev/null +++ b/ios/WorkTrack/Attendance/LocationProvider.swift @@ -0,0 +1,82 @@ +import CoreLocation + +/// One location fix, on demand. +/// +/// Deliberately not a continuous stream: the app needs a position at the moment +/// somebody punches, and watching location all day is both a battery cost and a +/// privacy claim this product does not need to make. "When in use" only — there +/// is no background tracking here and no plan for any. +@MainActor +final class LocationProvider: NSObject, ObservableObject, CLLocationManagerDelegate { + enum Failure: Error, Equatable { + /// The user said no, or the phone forbids it (parental controls, MDM). + case denied + /// A fix did not arrive in time — indoors, or a cold start. + case unavailable + } + + private let manager = CLLocationManager() + private var pending: CheckedContinuation? + + @Published private(set) var authorization: CLAuthorizationStatus + + override init() { + authorization = manager.authorizationStatus + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + } + + /// Asks once, then waits for a fix. Throws rather than returning a fake + /// position — a punch with an invented location is worse than no punch. + func currentLocation(timeout: TimeInterval = 15) async throws -> CLLocation { + if authorization == .notDetermined { + manager.requestWhenInUseAuthorization() + // The delegate callback flips `authorization`; give it a moment + // before deciding the answer was no. + try? await Task.sleep(nanoseconds: 1_500_000_000) + } + guard authorization == .authorizedWhenInUse || authorization == .authorizedAlways else { + throw Failure.denied + } + + return try await withThrowingTaskGroup(of: CLLocation.self) { group in + group.addTask { @MainActor in + try await withCheckedThrowingContinuation { continuation in + self.pending = continuation + self.manager.requestLocation() + } + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + throw Failure.unavailable + } + guard let first = try await group.next() else { throw Failure.unavailable } + group.cancelAll() + return first + } + } + + nonisolated func locationManager( + _ manager: CLLocationManager, didUpdateLocations locations: [CLLocation] + ) { + Task { @MainActor in + guard let location = locations.last else { return } + self.pending?.resume(returning: location) + self.pending = nil + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + Task { @MainActor in + self.pending?.resume(throwing: Failure.unavailable) + self.pending = nil + } + } + + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + Task { @MainActor in + self.authorization = manager.authorizationStatus + } + } +} diff --git a/ios/WorkTrack/Attendance/PunchCard.swift b/ios/WorkTrack/Attendance/PunchCard.swift new file mode 100644 index 0000000..6b843d7 --- /dev/null +++ b/ios/WorkTrack/Attendance/PunchCard.swift @@ -0,0 +1,171 @@ +import SwiftUI + +/// Check in, check out, and where you are standing while you do it. +struct PunchCard: View { + @ObservedObject var model: AttendanceViewModel + let todayISO: String + /// Nil when the company has face check-in switched off, which is the + /// default — then this card behaves exactly as it did before. + var faceService: FaceService? + var hasEnrolledFace: Bool = false + + @State private var capturing: FaceCaptureView.Purpose? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(isIn ? L.t("punch_state_in") : L.t("punch_state_out")) + .font(.headline) + if let first = model.today?.firstInAt { + Text("\(L.t("punch_first_in")) \(clock(first))") + .font(.caption).foregroundStyle(.secondary) + } + if let minutes = model.today?.workedMinutes, minutes > 0 { + Text("\(L.t("punch_worked")) \(L.n(minutes / 60)):\(L.n(String(format: "%02d", minutes % 60)))") + .font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + // Whether he is CHECKED IN — not whether he is on site. They + // are different facts and the card shows both; using the same + // words for each had it reading "on site" and "outside the + // site" at the same time. + Pill( + text: isIn ? L.t("chip_present") : L.t("chip_away"), + tone: isIn ? Palette.positive : Palette.neutral + ) + } + + if let evaluation = model.evaluation { + distanceLine(evaluation) + } + + Button { + Task { await model.punch(todayISO: todayISO) } + } label: { + Text(buttonLabel) + .fontWeight(.semibold) + .frame(maxWidth: .infinity, minHeight: 48) + } + .background(isIn ? Palette.accent : Palette.deep, in: RoundedRectangle(cornerRadius: 12)) + .foregroundStyle(.white) + .disabled(model.isPunching) + .opacity(model.isPunching ? 0.6 : 1) + + if let faceService { + Button { + // Enrol first if there is nothing to compare against; + // otherwise verify. Same screen either way. + capturing = hasEnrolledFace ? .verify : .enrol + } label: { + Label( + L.t(hasEnrolledFace ? "face_check_in" : "face_enrol"), + systemImage: "faceid" + ) + .font(.subheadline).fontWeight(.medium) + .frame(maxWidth: .infinity, minHeight: 42) + } + .foregroundStyle(Palette.deep) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(Palette.deep.opacity(0.35), lineWidth: 1) + ) + .disabled(model.isPunching) + .sheet(item: $capturing) { purpose in + FaceCaptureView( + purpose: purpose, + service: faceService, + onVerified: { token in + // The token is proof for THIS punch and nothing + // else; it is short-lived and the server re-checks + // its signature. + Task { await model.punch(todayISO: todayISO, faceToken: token) } + }, + onEnrolled: {} + ) + } + } + + if let outcome = model.outcome { + outcomeLine(outcome) + } + } + .padding(.vertical, 6) + } + + private var isIn: Bool { model.today?.isClockedIn ?? false } + + private var buttonLabel: String { + if model.isPunching { return L.t("punching") } + return isIn ? L.t("punch_out") : L.t("punch_in") + } + + @ViewBuilder + private func distanceLine(_ e: GeofenceEvaluator.Evaluation) -> some View { + if !e.fencesConfigured { + Text(L.t("punch_no_fences")).font(.caption).foregroundStyle(.secondary) + } else if let distance = e.distanceMeters { + HStack(spacing: 6) { + Image(systemName: e.insideFence ? "location.fill" : "location.slash") + Text(e.insideFence ? L.t("punch_inside") : L.t("punch_outside")) + Text("·") + // The number matters when it is bad news: "you are 340 m away" + // is actionable, "outside the site" alone is not. Past a + // kilometre metres stop being actionable and start being + // unreadable, so the unit follows the magnitude. + distanceText(distance) + } + .font(.caption) + .foregroundStyle(e.insideFence ? Palette.positive : Palette.warning) + } + } + + private func distanceText(_ meters: Double) -> Text { + let d = AfghanCalendar.distance(meters: meters, language: L.language) + return Text( + "\(L.t("punch_distance")) \(d.value) \(L.t(d.isKilometres ? "punch_kilometres" : "punch_meters"))" + ) + } + + @ViewBuilder + private func outcomeLine(_ outcome: AttendanceViewModel.Outcome) -> some View { + switch outcome { + case .accepted(let type): + label(type == .inbound ? L.t("punch_ok_in") : L.t("punch_ok_out"), + icon: "checkmark.circle.fill", tone: Palette.positive) + case .flagged: + // Recorded, not refused — and the app says which, because a worker + // who thinks he failed to check in will stand there trying again. + label(L.t("punch_flagged"), icon: "exclamationmark.triangle.fill", tone: Palette.warning) + case .queued: + // Not a failure, and the wording matters: a worker told his punch + // "failed" stands at the gate doing it again. + label(L.t("punch_queued"), icon: "tray.and.arrow.down.fill", tone: Palette.deep) + case .expired: + label(L.t("punch_expired"), icon: "clock.badge.exclamationmark", tone: Palette.negative) + case .failed(let message): + label(message, icon: "xmark.circle.fill", tone: Palette.negative) + } + } + + private func label(_ text: String, icon: String, tone: Color) -> some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: icon) + Text(text) + } + .font(.caption).foregroundStyle(tone) + } + + /// "۰۸:۱۵" from the server's ISO timestamp, in the company's zone. + private func clock(_ iso: String) -> String { + let parser = ISO8601DateFormatter() + parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date = parser.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) + guard let date else { return "" } + let out = DateFormatter() + out.dateFormat = "HH:mm" + out.timeZone = TimeZone(identifier: "Asia/Kabul") + return L.n(out.string(from: date)) + } +} diff --git a/ios/WorkTrack/Attendance/PunchOutbox.swift b/ios/WorkTrack/Attendance/PunchOutbox.swift new file mode 100644 index 0000000..b6f67de --- /dev/null +++ b/ios/WorkTrack/Attendance/PunchOutbox.swift @@ -0,0 +1,85 @@ +import Foundation + +/// A punch waiting to reach the server. +/// +/// `punchedAt` is the moment the worker actually punched, not the moment it is +/// finally sent. That distinction is the whole point: a man who checks in at +/// 07:00 in a valley with no signal was at work at 07:00, and his day must say +/// so even if the phone only finds a mast at noon. +struct QueuedPunch: Codable, Equatable, Identifiable { + /// The client-generated ULID. Sending it twice writes the same document + /// twice, which is once — so a retry can never double-count a day. + let id: String + let punchedAt: Date + let type: String + let latitude: Double + let longitude: Double + let accuracyMeters: Double + let insideFence: Bool + /// Failed attempts, kept only so a permanently rejected punch can be given + /// up on rather than retried until the end of time. + var attempts: Int = 0 + /// Present when the punch was face-verified. The server re-checks the + /// signature and derives `faceVerified` itself; a client claim is ignored. + var faceToken: String? + + var method: String { faceToken == nil ? "GPS" : "FACE" } +} + +/// The queue of punches that have not reached the server yet. +/// +/// Deliberately not the Android outbox's shape. That one drains through +/// /sync/push because it batches many resource types; a worker makes two to +/// four punches a day, and POST /attendance/punches is already idempotent on +/// the ULID, so sending them one at a time is simpler and fails in smaller +/// pieces. +@MainActor +final class PunchOutbox: ObservableObject { + /// Server rule: a punch older than this is refused as TOO_OLD + /// (MAX_BACKDATE_MS in backend/functions/src/services/punch.ts). Giving up + /// here means the app can say so instead of retrying something that will + /// never be accepted. + static let maxAge: TimeInterval = 7 * 24 * 60 * 60 + + @Published private(set) var pending: [QueuedPunch] = [] + + private let store: OfflineStore + private let fileName = "punch-outbox" + + init(store: OfflineStore = OfflineStore()) { + self.store = store + pending = store.load([QueuedPunch].self, from: fileName) ?? [] + } + + func enqueue(_ punch: QueuedPunch) { + // Same id twice is the same punch; the queue is a set, not a log. + guard !pending.contains(where: { $0.id == punch.id }) else { return } + pending.append(punch) + persist() + } + + func remove(id: String) { + pending.removeAll { $0.id == id } + persist() + } + + /// Punches too old for the server to accept, dropped from the queue. + /// Returned so the app can tell the worker rather than losing them quietly. + func discardExpired(now: Date = Date()) -> [QueuedPunch] { + let expired = pending.filter { now.timeIntervalSince($0.punchedAt) > Self.maxAge } + guard !expired.isEmpty else { return [] } + pending.removeAll { punch in expired.contains { $0.id == punch.id } } + persist() + return expired + } + + func recordAttempt(id: String) { + guard let index = pending.firstIndex(where: { $0.id == id }) else { return } + pending[index].attempts += 1 + persist() + } + + private func persist() { + store.save(pending, to: fileName) + } +} diff --git a/ios/WorkTrack/Attendance/RegularizationModels.swift b/ios/WorkTrack/Attendance/RegularizationModels.swift new file mode 100644 index 0000000..0d10c7f --- /dev/null +++ b/ios/WorkTrack/Attendance/RegularizationModels.swift @@ -0,0 +1,38 @@ +import Foundation + +enum RegularizationStatus: String, Codable { + case pending = "PENDING" + case approved = "APPROVED" + case rejected = "REJECTED" + case cancelled = "CANCELLED" + + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = RegularizationStatus(rawValue: raw) ?? .pending + } + + var label: String { + switch self { + case .pending: return L.t("leave_pending") + case .approved: return L.t("leave_approved") + case .rejected: return L.t("leave_rejected") + case .cancelled: return L.t("leave_cancelled") + } + } +} + +/// Asking a manager to correct one day. +/// +/// The worker never edits the day himself: he says what the times should have +/// been and why, and somebody with the authority decides. The original punches +/// are never touched either way — the correction is a separate record, which is +/// what makes the history auditable rather than editable. +struct Regularization: Codable, Identifiable, Equatable { + let id: String + let date: String + let requestedInAt: String? + let requestedOutAt: String? + let reason: String + let status: RegularizationStatus + let decisionNote: String? +} diff --git a/ios/WorkTrack/Auth/AuthStore.swift b/ios/WorkTrack/Auth/AuthStore.swift new file mode 100644 index 0000000..1770ac2 --- /dev/null +++ b/ios/WorkTrack/Auth/AuthStore.swift @@ -0,0 +1,218 @@ +import Foundation + +/// Who is signed in, and the token every request needs. +/// +/// The refresh token lives in the Keychain so the app opens signed in; the ID +/// token lives in memory only, because it is short-lived and there is nothing +/// to gain by writing it down. +@MainActor +final class AuthStore: ObservableObject { + enum State: Equatable { + case loading + case signedOut + case signedIn(Me) + } + + @Published private(set) var state: State = .loading + @Published var signInError: String? + @Published private(set) var isSigningIn = false + + private var session: FirebaseAuthREST.Session? + /// Guards against two foreground refreshes overlapping when the app is + /// flicked in and out of the switcher. + private var isRefreshing = false + private let store = OfflineStore() + private static let refreshKey = "refreshToken" + private static let meFile = "me" + + /// Refreshed a little early: a token that expires mid-request would + /// otherwise surface as a spurious sign-out. + private static let expiryMargin: TimeInterval = 60 + + private lazy var api = ApiClient { [weak self] in + guard let self else { throw ApiError.unauthenticated } + return try await self.validToken() + } + + /// Restores a session from the Keychain, or reports signed out. + func start() async { + // A language change re-fires this; re-restoring a live session could sign the worker out. + guard state == .loading else { return } + guard let refresh = Keychain.get(Self.refreshKey) else { + state = .signedOut + return + } + do { + session = try await FirebaseAuthREST.refresh(refresh) + Keychain.set(session!.refreshToken, for: Self.refreshKey) + let me: Me = try await api.get("me") + store.save(me, to: Self.meFile) + state = .signedIn(me) + } catch ApiError.offline { + // Offline at launch is NOT a sign-out. Showing the login screen + // here would be the worst possible answer: he cannot sign in + // without signal either, so the app would lock him out of the + // cached plan precisely when he needs it — on a site with no mast. + // + // The refresh token stays in the Keychain, the last known identity + // comes off the disk, and the screens serve what they cached. + if let cached = store.load(Me.self, from: Self.meFile) { + state = .signedIn(cached) + } else { + // Never signed in on this device, so there is nothing to show. + state = .signedOut + } + } catch { + // A real refusal — the token was revoked or the account is gone. + endSession() + } + } + + /// Ends a session the server has already ended, as opposed to one the + /// person chose to end. + /// + /// Deliberately narrower than `signOut()`: it leaves the work cache alone. + /// A token is also revoked when an admin merely RESETS a password, and + /// throwing away a punch the phone has not managed to send yet — over what + /// is, to the worker, a password change — would cost him the morning. + private func endSession() { + Keychain.remove(Self.refreshKey) + store.remove(Self.meFile) + session = nil + state = .signedOut + } + + /// Re-reads the signed-in person, for when something about THEM changed on + /// the server rather than anything changing on the phone. + /// + /// The case that prompted it: a manager switches face check-in on in the + /// portal and tells the worker to look. Features ride on `me`, and `me` + /// was only ever read in `start()` — at launch. So bringing the app back + /// from the switcher changed nothing and explained nothing, and the + /// feature simply stayed invisible until the app was force-quit. Nobody + /// guesses that. + /// + /// This must never be able to sign somebody out by accident, so the error + /// handling is deliberately lopsided: + /// + /// - offline, a 5xx, a body we could not read → keep the identity we + /// have. A bad minute on the server must not empty a site full of + /// phones onto the login screen. + /// - 401 → end the session, and that one is wanted: a revoked token is + /// exactly what disabling an employee produces, so somebody who has + /// left the company stops being in the app at the next foreground + /// instead of lingering until they happen to tap something. + func refreshMe() async { + guard case .signedIn(let current) = state, !isRefreshing else { return } + isRefreshing = true + defer { isRefreshing = false } + do { + let me: Me = try await api.get("me") + store.save(me, to: Self.meFile) + // Only when it actually differs. @Published fires on every + // assignment, identical or not, and this runs each time the app + // comes forward — re-rendering every screen for nothing. + if me != current { state = .signedIn(me) } + } catch { + switch Self.outcome(for: error) { + case .endSession: endSession() + case .keep: break + } + } + } + + /// What a failed refresh does to the session. + /// + /// Split out from `refreshMe` and made pure because it is the one part + /// that must not drift: getting it backwards does not throw or crash, it + /// quietly empties a site full of phones onto the login screen the first + /// time the server has a bad minute — at which point nobody can sign back + /// in either, because signing in needs the same server. + enum RefreshOutcome: Equatable { + /// Keep the identity we already have. + case keep + /// End the session, because the server has already ended it. + case endSession + } + + /// `nonisolated` because it is pure — it reads no state, so it has no + /// business needing the main actor to answer. + nonisolated static func outcome(for error: Error) -> RefreshOutcome { + if case ApiError.unauthenticated = error { return .endSession } + return .keep + } + + func signIn(email: String, password: String) async { + isSigningIn = true + signInError = nil + defer { isSigningIn = false } + do { + let s = try await FirebaseAuthREST.signIn( + email: email.trimmingCharacters(in: .whitespacesAndNewlines), + password: password + ) + session = s + Keychain.set(s.refreshToken, for: Self.refreshKey) + let me: Me = try await api.get("me") + store.save(me, to: Self.meFile) + state = .signedIn(me) + } catch ApiError.offline { + signInError = L.t("err_offline") + } catch ApiError.problem(_, let code, _) where code.hasPrefix("EMAIL_") + || code.hasPrefix("INVALID_") || code == "MISSING_PASSWORD" { + signInError = L.t("err_bad_credentials") + } catch ApiError.unauthenticated { + signInError = L.t("err_bad_credentials") + } catch { + signInError = L.t("err_generic") + } + } + + func signOut() { + // Signing out is explicit, so everything held for this person goes: + // the token, the identity, the cached day and any queued punch. A + // shared phone must not hand the next worker the last one's plan. + Keychain.remove(Self.refreshKey) + store.remove(Self.meFile) + WorkCache().clear() + session = nil + state = .signedOut + } + + /// A token good for the next request, refreshing it if it is about to go. + func validToken() async throws -> String { + guard let current = session else { throw ApiError.unauthenticated } + if current.expiresAt.timeIntervalSinceNow > Self.expiryMargin { + return current.idToken + } + let refreshed = try await FirebaseAuthREST.refresh(current.refreshToken) + session = refreshed + Keychain.set(refreshed.refreshToken, for: Self.refreshKey) + return refreshed.idToken + } + + /// The shared client, so screens do not each build their own. + var client: ApiClient { api } +} + +/// The signed-in person, from GET /v1/me. +struct Me: Codable, Equatable { + let employeeId: String + let companyId: String + let displayName: String + let companyName: String + let roles: [String] + /// Whether this employee has already enrolled a face. + let faceEnrolled: Bool? + /// The company's module switches. Face check-in is off by default + /// (DEFAULT_SETTINGS in backend/functions/src/services/settings.ts), so it + /// must not appear for a company that has not asked for it. + let features: Features? + + struct Features: Codable, Equatable { + let faceRecognition: Bool? + } + + var faceEnabled: Bool { features?.faceRecognition == true } + var hasFace: Bool { faceEnrolled == true } +} diff --git a/ios/WorkTrack/Auth/FirebaseAuthREST.swift b/ios/WorkTrack/Auth/FirebaseAuthREST.swift new file mode 100644 index 0000000..2e797a4 --- /dev/null +++ b/ios/WorkTrack/Auth/FirebaseAuthREST.swift @@ -0,0 +1,100 @@ +import Foundation + +/// Firebase Authentication over its REST API, rather than the Firebase SDK. +/// +/// The SDK would pull a large dependency tree in for two calls — sign in, and +/// exchange a refresh token — both of which are a POST with a JSON body. This +/// keeps the app buildable from a checked-in spec with no package resolution, +/// which matters while the project is still being shaped. If push +/// notifications or Firestore arrive later, the SDK comes with them and this +/// file goes. +enum FirebaseAuthREST { + struct Session { + let idToken: String + let refreshToken: String + let expiresAt: Date + } + + private static let apiKey = Backend.current.firebaseAPIKey + + static func signIn(email: String, password: String) async throws -> Session { + let body: [String: Any] = [ + "email": email, "password": password, "returnSecureToken": true, + ] + let json = try await post( + "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword", + body: body + ) + guard let idToken = json["idToken"] as? String, + let refresh = json["refreshToken"] as? String, + let expiresIn = json["expiresIn"] as? String, + let seconds = TimeInterval(expiresIn) else { + throw ApiError.malformedResponse + } + return Session( + idToken: idToken, + refreshToken: refresh, + expiresAt: Date().addingTimeInterval(seconds) + ) + } + + static func refresh(_ refreshToken: String) async throws -> Session { + let json = try await post( + "https://securetoken.googleapis.com/v1/token", + body: ["grant_type": "refresh_token", "refresh_token": refreshToken], + form: true + ) + guard let idToken = json["id_token"] as? String, + let refresh = json["refresh_token"] as? String, + let expiresIn = json["expires_in"] as? String, + let seconds = TimeInterval(expiresIn) else { + throw ApiError.unauthenticated + } + return Session( + idToken: idToken, + refreshToken: refresh, + expiresAt: Date().addingTimeInterval(seconds) + ) + } + + private static func post( + _ urlString: String, + body: [String: Any], + form: Bool = false + ) async throws -> [String: Any] { + var components = URLComponents(string: urlString)! + components.queryItems = [URLQueryItem(name: "key", value: apiKey)] + var request = URLRequest(url: components.url!) + request.httpMethod = "POST" + + if form { + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = Data( + body.map { "\($0.key)=\($0.value)" }.joined(separator: "&").utf8 + ) + } else { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + } + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw ApiError.offline + } + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ApiError.malformedResponse + } + guard (200..<300).contains(status) else { + // Firebase reports EMAIL_NOT_FOUND / INVALID_PASSWORD / + // INVALID_LOGIN_CREDENTIALS separately; to somebody signing in they + // are one thing, and saying which one is true is a favour to + // whoever is guessing. + let inner = (json["error"] as? [String: Any])?["message"] as? String ?? "AUTH_FAILED" + throw ApiError.problem(status: status, code: inner, detail: inner) + } + return json + } +} diff --git a/ios/WorkTrack/Auth/Keychain.swift b/ios/WorkTrack/Auth/Keychain.swift new file mode 100644 index 0000000..6de43f7 --- /dev/null +++ b/ios/WorkTrack/Auth/Keychain.swift @@ -0,0 +1,69 @@ +import Foundation +import Security + +/// The Keychain, for the one secret this app holds: the refresh token. +/// +/// Not UserDefaults, which is a plist any backup reads. And the Keychain +/// specifically because it SURVIVES the app being deleted and reinstalled — +/// which matters beyond secrecy: whenever this app grows a device identity, it +/// has to live here too, or reinstalling would hand the phone a new identity +/// and take a fresh licence seat every time. See docs/15-ios-app.md. +enum Keychain { + private static let service = "app.worktrack.auth" + + /// Writes, and says whether it worked. + /// + /// The return value is not decoration. Swallowing the OSStatus here meant a + /// build whose entitlements the Keychain rejected (-34018) failed every + /// write in silence, and the only symptom was the app asking for the + /// password on every launch — which reads as a login bug, not a storage + /// one, and sends you looking in the wrong file. + @discardableResult + static func set(_ value: String, for key: String) -> Bool { + let data = Data(value.utf8) + var query = baseQuery(key) + SecItemDelete(query as CFDictionary) + query[kSecValueData as String] = data + // Readable only once the device has been unlocked at least once since + // boot, and never migrated to another device by a backup. + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let status = SecItemAdd(query as CFDictionary, nil) + if status != errSecSuccess { + // Logged, not fatal. assertionFailure was wrong twice over: it + // kills the app on a real phone where the Keychain can genuinely + // be unavailable — locked before first unlock, or storage full — + // and it compiles out of a release build, so the silent failure + // this exists to prevent would come straight back in the build + // customers actually run. + // + // The caller decides what a failed write means. For a refresh + // token it means "this session will not survive a relaunch", + // which is survivable; being unable to open the app is not. + print("[WorkTrack] Keychain write failed for \(key): OSStatus \(status)") + return false + } + return true + } + + static func get(_ key: String) -> String? { + var query = baseQuery(key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var out: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess, + let data = out as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func remove(_ key: String) { + SecItemDelete(baseQuery(key) as CFDictionary) + } + + private static func baseQuery(_ key: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + } +} diff --git a/ios/WorkTrack/Auth/SignInView.swift b/ios/WorkTrack/Auth/SignInView.swift new file mode 100644 index 0000000..096b0dc --- /dev/null +++ b/ios/WorkTrack/Auth/SignInView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +struct SignInView: View { + @EnvironmentObject private var auth: AuthStore + @State private var email = "" + @State private var password = "" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text(L.t("sign_in_title")).font(.largeTitle).fontWeight(.bold) + Text(L.t("sign_in_subtitle")) + .font(.subheadline).foregroundStyle(.secondary) + } + .padding(.top, 48) + + field(L.t("email")) { + TextField("", text: $email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + // An email is always Latin script; letting it inherit + // the RTL layout puts the caret on the wrong side. + .environment(\.layoutDirection, .leftToRight) + .multilineTextAlignment(.leading) + } + + field(L.t("password")) { + SecureField("", text: $password) + .textContentType(.password) + .environment(\.layoutDirection, .leftToRight) + .multilineTextAlignment(.leading) + } + + if let error = auth.signInError { + Text(error).font(.footnote).foregroundStyle(Palette.negative) + } + + Button { + Task { await auth.signIn(email: email, password: password) } + } label: { + Text(auth.isSigningIn ? L.t("signing_in") : L.t("sign_in")) + .fontWeight(.semibold) + .frame(maxWidth: .infinity, minHeight: 50) + } + .background(Palette.deep, in: RoundedRectangle(cornerRadius: 12)) + .foregroundStyle(.white) + .disabled(auth.isSigningIn || email.isEmpty || password.isEmpty) + .opacity(auth.isSigningIn || email.isEmpty || password.isEmpty ? 0.6 : 1) + + LanguagePicker() + .padding(.top, 8) + } + .padding(24) + } + } + + @ViewBuilder + private func field(_ label: String, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label).font(.footnote).foregroundStyle(.secondary) + content() + .padding(12) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10)) + } + } +} + +/// Language is a first-class choice, not a setting buried three screens deep. +struct LanguagePicker: View { + @EnvironmentObject private var app: AppState + + var body: some View { + HStack(spacing: 8) { + ForEach(Language.allCases, id: \.self) { lang in + Button(lang.label) { app.setLanguage(lang) } + .font(.footnote) + .fontWeight(app.language == lang ? .semibold : .regular) + .foregroundStyle(app.language == lang ? Palette.deep : .secondary) + .padding(.horizontal, 12).padding(.vertical, 6) + .background( + app.language == lang ? Palette.deep.opacity(0.1) : .clear, + in: Capsule() + ) + } + } + } +} diff --git a/ios/WorkTrack/Core/AfghanCalendar.swift b/ios/WorkTrack/Core/AfghanCalendar.swift new file mode 100644 index 0000000..67631d6 --- /dev/null +++ b/ios/WorkTrack/Core/AfghanCalendar.swift @@ -0,0 +1,122 @@ +import Foundation + +/// Dates the way Afghanistan reads them. +/// +/// Foundation's `.persian` calendar already implements Solar Hijri arithmetic, +/// and it was checked against WorkTrack's own implementation +/// (web/src/shamsi/solarHijri.ts) across Nowruz and a leap day before this was +/// written — same year, month and day on every case. So the arithmetic is +/// Foundation's; only the month NAMES are ours, because Afghanistan uses the +/// Arabic-derived names (حمل، ثور، جوزا…) where Iran uses the Persian ones +/// (فروردین، اردیبهشت…) for the very same months. +enum AfghanCalendar { + /// Month names by index 1…12, per locale. Mirrors SHAMSI_MONTHS in + /// web/src/i18n/LocaleProvider.tsx. + fileprivate static let monthsInternal = true + private static let months: [Language: [String]] = [ + .dari: ["حمل", "ثور", "جوزا", "سرطان", "اسد", "سنبله", + "میزان", "عقرب", "قوس", "جدی", "دلو", "حوت"], + .pashto: ["وری", "غویی", "غبرګولی", "چنګاښ", "زمری", "وږی", + "تله", "لړم", "لیندۍ", "مرغومی", "سلواغه", "کب"], + .english: ["Hamal", "Sawr", "Jawza", "Saratan", "Asad", "Sunbula", + "Mizan", "Aqrab", "Qaws", "Jadi", "Dalwa", "Hut"], + ] + + private static var calendar: Calendar = { + var c = Calendar(identifier: .persian) + // The company's day, not the phone's. A worker whose handset is still + // set to another country must see the same date as the site he is on. + c.timeZone = TimeZone(identifier: "Asia/Kabul") ?? .current + return c + }() + + /// "۱۷ سنبله ۱۴۰۵" — the form a date is spoken in. + static func format(_ date: Date, language: Language, withYear: Bool = true) -> String { + let parts = calendar.dateComponents([.year, .month, .day], from: date) + let name = months[language]?[max(0, min(11, (parts.month ?? 1) - 1))] ?? "" + let base = withYear + ? "\(parts.day ?? 0) \(name) \(parts.year ?? 0)" + : "\(parts.day ?? 0) \(name)" + return language.localizesDigits ? easternDigits(base) : base + } + + /// The Solar Hijri year we are in — what the payslips endpoint expects. + static func currentShamsiYear(now: Date = Date()) -> Int { + calendar.dateComponents([.year], from: now).year ?? 1405 + } + + /// The month name for a Solar Hijri month number, for a payslip heading. + static func monthName(_ month: Int, language: Language) -> String { + months[language]?[max(0, min(11, month - 1))] ?? "" + } + + /// Money, the way it is written here: "۳۱٬۸۶۶٫۶۶ افغانی". + /// + /// Grouped, two decimals only when there are any, and the digits localised + /// with everything else — a Latin-numeral figure in a Dari sentence reads + /// as though it belongs to a different document. + static func money(_ amount: Double, currency: String, language: Language) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + // The separators follow the SCRIPT, not the currency: Arabic-Indic + // marks beside Latin digits ("1٬234٫50") is neither one convention nor + // the other, and reads as a rendering fault. + formatter.groupingSeparator = language.localizesDigits ? "\u{066C}" : "," + formatter.decimalSeparator = language.localizesDigits ? "\u{066B}" : "." + formatter.maximumFractionDigits = amount == amount.rounded() ? 0 : 2 + formatter.minimumFractionDigits = formatter.maximumFractionDigits + let number = formatter.string(from: NSNumber(value: amount)) ?? "\(amount)" + let localised = language.localizesDigits ? easternDigits(number) : number + let name = currency == "AFN" && language != .english ? "افغانی" : currency + return "\(localised) \(name)" + } + + /// A distance somebody can read at a glance, and act on. + /// + /// The point of showing a number at all is "you are 340 m away" — close + /// enough to walk. Printing the raw metre count assumed the phone was + /// somewhere near the site; the first run on a real handset was in Ottawa + /// with the site in Kabul and it read "۱۰۴۵۷۲۲۰ متر", an eight-digit run + /// with no separators that nobody can parse as ten thousand kilometres. + /// + /// So: metres while metres are walkable, kilometres past that, and the + /// separators follow the script for the same reason `money` does. + static func distance( + meters: Double, + language: Language, + ) -> (value: String, isKilometres: Bool) { + let kilometres = meters >= 1000 + let amount = kilometres ? meters / 1000 : meters + + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.groupingSeparator = language.localizesDigits ? "\u{066C}" : "," + formatter.decimalSeparator = language.localizesDigits ? "\u{066B}" : "." + // One decimal is worth having at 1.4 km and noise at 10,457 km. + formatter.maximumFractionDigits = kilometres && amount < 10 ? 1 : 0 + formatter.minimumFractionDigits = 0 + + let number = formatter.string(from: NSNumber(value: amount)) ?? "\(Int(amount))" + return (language.localizesDigits ? easternDigits(number) : number, kilometres) + } + + /// Parses a plain `yyyy-MM-dd` from the API. These carry no time and no + /// zone; reading them as UTC keeps the calendar date the server meant. + static func parseISODate(_ iso: String) -> Date? { + let f = DateFormatter() + f.calendar = Calendar(identifier: .gregorian) + f.timeZone = TimeZone(identifier: "UTC") + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + return f.date(from: iso) + } + + /// ۰–۹ for Dari and Pashto; Latin digits look wrong in both. + static func easternDigits(_ s: String) -> String { + let eastern = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"] + return String(s.map { ch -> Character in + guard let d = ch.wholeNumberValue, (0...9).contains(d), ch.isASCII else { return ch } + return Character(eastern[d]) + }) + } +} diff --git a/ios/WorkTrack/Core/ApiClient.swift b/ios/WorkTrack/Core/ApiClient.swift new file mode 100644 index 0000000..826244e --- /dev/null +++ b/ios/WorkTrack/Core/ApiClient.swift @@ -0,0 +1,73 @@ +import Foundation + +/// Talks to the WorkTrack API. +/// +/// `/v1` is the contract this app shares with the portal and the Android app — +/// no code is shared with either, and none needs to be. Everything here is the +/// same envelope, the same problem+json, and the same bearer token. +/// +/// Deliberately does NOT send `X-Device-Id`. The licence counts phones running +/// the Android app; an iOS build has no seat to claim and the server's device +/// guard treats a missing header as "not a licensed device" +/// (backend/functions/src/middleware/deviceGuard.ts). Sending an invented id +/// here would silently consume a customer's seats. +actor ApiClient { + private let baseURL: URL + private let session: URLSession + private let tokenProvider: () async throws -> String + + init( + baseURL: URL = Backend.current.apiBaseURL, + session: URLSession = .shared, + tokenProvider: @escaping () async throws -> String + ) { + self.baseURL = baseURL + self.session = session + self.tokenProvider = tokenProvider + } + + func get(_ path: String, query: [String: String] = [:]) async throws -> T { + var components = URLComponents( + url: baseURL.appendingPathComponent(path), + resolvingAgainstBaseURL: false + )! + if !query.isEmpty { + components.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) } + } + var request = URLRequest(url: components.url!) + request.httpMethod = "GET" + return try await send(request) + } + + func post(_ path: String, body: [String: Any]) async throws -> T { + var request = URLRequest(url: baseURL.appendingPathComponent(path)) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + return try await send(request) + } + + private func send(_ base: URLRequest) async throws -> T { + var request = base + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(try await tokenProvider())", forHTTPHeaderField: "Authorization") + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + // A site with no signal is the normal case, not the exception. + throw ApiError.offline + } + + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(status) else { + throw ApiError.from(status: status, body: data) + } + do { + return try JSONDecoder().decode(Envelope.self, from: data).data + } catch { + throw ApiError.malformedResponse + } + } +} diff --git a/ios/WorkTrack/Core/ApiError.swift b/ios/WorkTrack/Core/ApiError.swift new file mode 100644 index 0000000..a591d55 --- /dev/null +++ b/ios/WorkTrack/Core/ApiError.swift @@ -0,0 +1,53 @@ +import Foundation + +/// An error the API returned, or the reason we never reached it. +/// +/// Mirrors the server's RFC 7807 problem+json (see backend/functions/src/lib/ +/// errors.ts) so the codes here are the same strings the Android app and the +/// portal already switch on. +enum ApiError: Error, Equatable { + /// No network, DNS failure, timeout — anything that never reached a server. + case offline + /// The token is missing or expired and could not be refreshed. + case unauthenticated + /// A problem+json response. + case problem(status: Int, code: String, detail: String) + /// A 2xx whose body was not the shape we expected. + case malformedResponse + + var isRetryable: Bool { + switch self { + case .offline: return true + case .problem(let status, _, _): return status >= 500 + default: return false + } + } +} + +/// The server wraps every success in `{ "data": … }`. +struct Envelope: Decodable { + let data: T +} + +private struct Problem: Decodable { + let code: String? + let detail: String? + let title: String? + let status: Int? +} + +extension ApiError { + /// Builds the error from a non-2xx response body, falling back to the + /// status when the body is not problem+json. + static func from(status: Int, body: Data) -> ApiError { + if status == 401 { return .unauthenticated } + if let p = try? JSONDecoder().decode(Problem.self, from: body) { + return .problem( + status: status, + code: p.code ?? "HTTP_\(status)", + detail: p.detail ?? p.title ?? "Request failed" + ) + } + return .problem(status: status, code: "HTTP_\(status)", detail: "Request failed") + } +} diff --git a/ios/WorkTrack/Core/Environment.swift b/ios/WorkTrack/Core/Environment.swift new file mode 100644 index 0000000..2c6bee4 --- /dev/null +++ b/ios/WorkTrack/Core/Environment.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Which backend this build talks to. +/// +/// Two environments exist and they must never be confused: the demo tenant +/// publishes its own password, so a build pointing at it must not be handed to +/// a real company. +enum Backend { + case demo + case production + + /// Tied to the build configuration, not to a constant somebody remembers + /// to flip. + /// + /// It used to be a hand-edited constant, and the failure mode was silent + /// and total: the first build uploaded to TestFlight was a Release build + /// still set to `.demo`, so it looked and behaved exactly like the real + /// app while writing to the demo tenant. Nothing on screen said so. Sent + /// to the App Store, every customer would have been keeping their + /// attendance and payroll in a database whose password is published on + /// purpose. + /// + /// A Release build is the only kind that reaches anybody — archive, + /// TestFlight, App Store — so Release means production and there is no + /// step left to forget. Development stays on the demo tenant: it is + /// seeded, safe to write to, and its password is public by design. + /// + /// A demo build for customers to try, if it is ever wanted, belongs in a + /// separate app the way Android does it with `applicationIdSuffix + /// ".demo"` — two apps side by side, not one app in two moods. + #if DEBUG + static let current: Backend = .demo + #else + static let current: Backend = .production + #endif + + var apiBaseURL: URL { + switch self { + case .demo: return URL(string: "https://demo.linumic.com/v1")! + case .production: return URL(string: "https://worktrack-prod.web.app/v1")! + } + } + + /// Firebase Web API key. Not a secret — it identifies the project, and + /// every client that signs in needs it. Authorization is the ID token. + var firebaseAPIKey: String { + switch self { + case .demo: return "AIzaSyA1Kb5qR8UKXLTkpR3o0Qz7xPUT9i7wAxo" + case .production: return "AIzaSyBhGGgbBqhdsJYpM9FpQld28jyhvEfqWPA" + } + } +} diff --git a/ios/WorkTrack/Core/Localization.swift b/ios/WorkTrack/Core/Localization.swift new file mode 100644 index 0000000..52367e8 --- /dev/null +++ b/ios/WorkTrack/Core/Localization.swift @@ -0,0 +1,522 @@ +import Foundation + +/// The three languages, in the order the product treats them. +enum Language: String, CaseIterable { + case dari = "fa" + case pashto = "ps" + case english = "en" + + var isRTL: Bool { self != .english } + var localizesDigits: Bool { self != .english } + + var label: String { + switch self { + case .dari: return "دری" + case .pashto: return "پښتو" + case .english: return "English" + } + } +} + +/// UI strings, held in dictionaries rather than .strings files. +/// +/// That is the same shape the portal uses (web/src/i18n/strings.ts) and it is +/// deliberate: the three languages have to be edited side by side to stay +/// honest with each other, and three .strings files make that harder, not +/// easier. Keys mirror the portal's where the screen is the same. +enum L { + static var language: Language = .dari + + static func t(_ key: String) -> String { + dictionaries[language]?[key] ?? dictionaries[.dari]?[key] ?? key + } + + /// Digits localized too, so numbers inside a sentence match the script. + static func n(_ value: some CustomStringConvertible) -> String { + language.localizesDigits + ? AfghanCalendar.easternDigits(value.description) + : value.description + } + + /// The keys each language defines. Exposed for the parity test: with three + /// dictionaries edited by hand, a string added to Dari and forgotten in + /// Pashto shows the raw key on screen to exactly the users least likely to + /// report it. + static func keys(for language: Language) -> Set { + Set(dictionaries[language]?.keys ?? [:].keys) + } + + private static let dictionaries: [Language: [String: String]] = [ + .dari: [ + "app_name": "ورک‌ترک", + "sign_in_title": "خوش آمدید", + "sign_in_subtitle": "مدیریت هوشمند نیروی کار برای افغانستان", + "email": "ایمیل کاری", + "password": "رمز عبور", + "sign_in": "ورود", + "signing_in": "در حال ورود…", + "sign_out": "خروج", + "err_bad_credentials": "ایمیل یا رمز عبور درست نیست", + "err_offline": "به انترنت وصل نیستید", + "err_generic": "مشکلی پیش آمد", + "work_title": "کار شما", + "work_today": "امروز", + "work_next": "روز کاری بعد", + "work_none_today": "برای امروز کاری به شما تعیین نشده. اگر مطمئن نیستید، از سرپرست خود بپرسید.", + "work_none_next": "برای این روز هنوز کاری تعیین نشده.", + "work_weekend": "این روز رخصتی هفته‌وار است.", + "work_holiday": "این روز رخصتی رسمی است.", + "work_team": "کار تیمی", + "work_solo": "انفرادی", + "work_with": "همراه", + "work_start": "شروع کردم", + "work_finish": "تمام شد", + "status_planned": "پلان‌شده", + "status_in_progress": "در جریان", + "status_done": "انجام شد", + "status_blocked": "متوقف", + "chip_present": "حاضر", + "chip_away": "خارج", + "offline_now": "بدون انترنت — آنچه می‌بینید ذخیره‌شده است", + "offline_stale": "در حال به‌روزرسانی…", + "offline_pending": "حاضری ثبت شد و در گوشی محفوظ است؛ با وصل شدن انترنت فرستاده می‌شود", + "offline_updated": "آخرین به‌روزرسانی", + "offline_minutes": "دقیقه پیش", + "offline_hours": "ساعت پیش", + "punch_queued": "ثبت شد و در گوشی محفوظ ماند. با انترنت خودش فرستاده می‌شود.", + "punch_expired": "حاضری‌های خیلی قدیمی فرستاده نشدند. از سرپرست خود بخواهید روز را اصلاح کند.", + "common_close": "بستن", + "face_enrol_prompt": "چهرهٔ خود را در بیضی نگه دارید و دکمه را بزنید. عکس شما ذخیره نمی‌شود؛ فقط یک کد عددی ساخته می‌شود.", + "face_verify_prompt": "به دوربین نگاه کنید و دکمه را بزنید", + "face_checking": "در حال بررسی…", + "face_not_recognised": "شناخته نشد. کمی نزدیک‌تر شوید و در روشنایی بهتر دوباره امتحان کنید.", + "face_no_face": "چهره‌ای دیده نشد", + "face_many_faces": "بیش از یک چهره در تصویر است", + "face_too_far": "خیلی دور هستید. نزدیک‌تر بیایید.", + "face_needs_connection": "برای تایید چهره به انترنت نیاز است. با GPS حاضری بزنید.", + "face_already_enrolled": "چهرهٔ شما قبلاً ثبت شده است", + "face_camera_denied": "اجازهٔ دوربین داده نشده. از تنظیمات آن را روشن کنید.", + "face_camera_unavailable": "این دستگاه دوربین ندارد. حاضری با چهره روی آن ممکن نیست.", + "face_check_in": "حاضری با چهره", + "face_enrol": "ثبت چهره", + "tab_work": "کار", + "tab_leave": "رخصتی", + "tab_pay": "معاش", + "leave_title": "رخصتی", + "leave_balances": "بیلانس رخصتی", + "leave_no_balances": "برای شما بیلانس رخصتی ثبت نشده", + "leave_my_requests": "درخواست‌های من", + "leave_none_yet": "هنوز درخواستی نداده‌اید", + "leave_days_left": "روز باقی‌مانده", + "leave_used": "استفاده‌شده", + "leave_pending_days": "در انتظار", + "leave_days": "روز", + "leave_apply": "درخواست رخصتی", + "leave_type": "نوع رخصتی", + "leave_from": "از", + "leave_to": "تا", + "leave_reason": "دلیل", + "leave_reason_hint": "چرا رخصتی می‌خواهید؟", + "leave_send": "ارسال", + "leave_cancel": "لغو درخواست", + "leave_pending": "در انتظار", + "leave_approved": "تایید شد", + "leave_rejected": "رد شد", + "leave_cancelled": "لغو شد", + "leave_no_balance": "بیلانس رخصتی شما برای این تعداد روز کافی نیست", + "pay_title": "فیش معاش", + "pay_none": "برای این سال فیشی وجود ندارد", + "pay_net": "خالص پرداختی", + "pay_gross": "مجموع ناخالص", + "pay_earnings": "عواید", + "pay_deductions": "کسرات", + "pay_income_tax": "مالیهٔ معاش", + "pay_basic": "معاش اساسی", + "pay_absence": "کسر غیرحاضری", + "leave_type_annual": "رخصتی سالانه", + "leave_type_sick": "رخصتی مریضی", + "pay_total_deductions": "مجموع کسرات", + "pay_days": "روزها", + "pay_worked_days": "روزهای کاری", + "pay_paid_leave": "رخصتی با معاش", + "pay_lop": "غیرحاضری بدون معاش", + "pay_employer_cost": "سهم شرکت", + "pay_employer_cost_note": "این مبلغ را شرکت جدا می‌پردازد و از معاش شما کم نشده است.", + "tab_history": "حاضری", + "hist_title": "حاضری من", + "hist_none": "برای این مدت حاضری ثبت نشده", + "hist_present": "حاضر", + "hist_absent": "غیرحاضر", + "hist_on_leave": "رخصت", + "hist_holiday": "رخصتی رسمی", + "hist_weekend": "رخصتی هفته‌وار", + "hist_half_day": "نیم‌روز", + "hist_request_correction": "درخواست اصلاح", + "hist_correction_pending": "درخواست اصلاح شما در انتظار تایید است", + "hist_fix_in": "ساعت ورود اشتباه است", + "hist_fix_out": "ساعت خروج اشتباه است", + "hist_in_time": "ساعت ورود", + "hist_out_time": "ساعت خروج", + "hist_correction_note": "فقط همان ساعتی را که اشتباه است انتخاب کنید. سرپرست شما آن را بررسی می‌کند؛ حاضری اصلی پاک نمی‌شود.", + "hist_reason_hint": "چه اتفاقی افتاد؟ مثلاً: فراموش کردم خروج بزنم", + "hist_pending": "در حال محاسبه", + "common_cancel": "لغو", + "tab_ann": "اعلانات", + "ann_title": "اعلانات شرکت", + "ann_none": "فعلاً اعلانی نیست", + "ann_important": "مهم", + "ann_urgent": "عاجل", + "tab_profile": "پروفایل", + "profile_language": "زبان", + "profile_sync": "وضعیت ارسال", + "profile_all_sent": "همه‌چیز فرستاده شده", + "profile_pending": "در گوشی محفوظ، هنوز فرستاده نشده", + "profile_pending_note": "به‌محض وصل شدن انترنت خودش فرستاده می‌شود. تا آن وقت از حساب خارج نشوید.", + "profile_sign_out_confirm": "از حساب خارج می‌شوید؟", + "profile_sign_out_pending": "حاضری فرستاده‌نشده روی این گوشی هست. اگر خارج شوید پاک می‌شود.", + "profile_version": "نسخه", + "lock_title": "برای باز کردن، هویت خود را تایید کنید", + "lock_unlock": "باز کردن", + "lock_passcode": "رمز دستگاه", + "lock_reason": "برای باز کردن ورک‌ترک", + "lock_setting": "قفل اپ", + "lock_note": "هر بار که اپ را باز می‌کنید، پرسیده می‌شود. حاضری فرستاده‌نشدهٔ شما دست‌نخورده می‌ماند.", + "lock_unavailable": "این گوشی قفل ندارد. اول در تنظیمات گوشی رمز بگذارید.", + "ann_all": "همهٔ اعلانات", + "punch_in": "ثبت ورود", + "punch_out": "ثبت خروج", + "punching": "در حال ثبت…", + "punch_state_in": "حاضری ورود ثبت شده", + "punch_state_out": "هنوز حاضری نزده‌اید", + "punch_first_in": "اولین ورود", + "punch_worked": "کارکرد", + "punch_ok_in": "ورود ثبت شد", + "punch_ok_out": "خروج ثبت شد", + "punch_flagged": "حاضری شما حساب نشد — بیرون از ساحهٔ کاری بودید. ثبت شد و سرپرست می‌بیند؛ اگر اشتباه است، درخواست اصلاح بدهید.", + "punch_outside": "بیرون از ساحه", + "punch_inside": "داخل ساحه", + "punch_distance": "فاصله تا ساحه", + "punch_meters": "متر", + "punch_kilometres": "کیلومتر", + "punch_no_fences": "برای این شرکت ساحهٔ کاری تعیین نشده", + "err_location_denied": "اجازهٔ موقعیت داده نشده. از تنظیمات آن را روشن کنید.", + "err_location_unavailable": "موقعیت پیدا نشد. زیر آسمان باز دوباره امتحان کنید.", + "hours_minutes": "ساعت و دقیقه", + "retry": "دوباره", + ], + .pashto: [ + "app_name": "ورک‌ټرک", + "sign_in_title": "ښه راغلاست", + "sign_in_subtitle": "د افغانستان لپاره د کاري ځواک هوښیار مدیریت", + "email": "کاري ایمیل", + "password": "پټنوم", + "sign_in": "ننوتل", + "signing_in": "په ننوتلو کې…", + "sign_out": "وتل", + "err_bad_credentials": "ایمیل یا پټنوم سم نه دی", + "err_offline": "له انټرنټ سره نه یاست وصل", + "err_generic": "ستونزه رامنځته شوه", + "work_title": "ستاسو کار", + "work_today": "نن", + "work_next": "راتلونکې کاري ورځ", + "work_none_today": "د نن ورځې لپاره تاسو ته کار نه دی ټاکل شوی. که ډاډه نه یاست، له خپل سرپرست وپوښتئ.", + "work_none_next": "د دې ورځې لپاره لا کار نه دی ټاکل شوی.", + "work_weekend": "دا ورځ د اونۍ رخصتي ده.", + "work_holiday": "دا ورځ رسمي رخصتي ده.", + "work_team": "ټیمي کار", + "work_solo": "انفرادي", + "work_with": "ملګري", + "work_start": "پیل مې کړ", + "work_finish": "بشپړ شو", + "status_planned": "پلان شوی", + "status_in_progress": "روان", + "status_done": "ترسره شو", + "status_blocked": "درېدلی", + "chip_present": "حاضر", + "chip_away": "بهر", + "offline_now": "پرته له انټرنټه — څه چې ګورئ خوندي شوي دي", + "offline_stale": "په تازه کولو کې…", + "offline_pending": "حاضري ثبت شوه او په موبایل کې خوندي ده؛ د انټرنټ په وصلېدو به ولېږل شي", + "offline_updated": "وروستی تازه کول", + "offline_minutes": "دقیقې مخکې", + "offline_hours": "ساعته مخکې", + "punch_queued": "ثبت شو او په موبایل کې خوندي پاتې شو. د انټرنټ سره به پخپله ولېږل شي.", + "punch_expired": "ډېرې زړې حاضرۍ ونه لېږل شوې. له سرپرست وغواړئ ورځ سمه کړي.", + "common_close": "بندول", + "face_enrol_prompt": "خپل مخ په بیضي کې ونیسئ او تڼۍ کېکاږئ. ستاسو عکس نه خوندي کیږي؛ یوازې یو عددي کوډ جوړیږي.", + "face_verify_prompt": "کمرې ته وګورئ او تڼۍ کېکاږئ", + "face_checking": "په کتلو کې…", + "face_not_recognised": "ونه پېژندل شو. لږ نږدې شئ او په ښه رڼا کې بیا هڅه وکړئ.", + "face_no_face": "مخ ونه لیدل شو", + "face_many_faces": "په عکس کې له یو څخه ډېر مخونه دي", + "face_too_far": "ډېر لرې یاست. نږدې راشئ.", + "face_needs_connection": "د مخ تاییدولو لپاره انټرنټ پکار دی. په GPS سره حاضري ووهئ.", + "face_already_enrolled": "ستاسو مخ مخکې ثبت شوی دی", + "face_camera_denied": "د کمرې اجازه نه ده ورکړل شوې. له تنظیماتو یې فعال کړئ.", + "face_camera_unavailable": "دې وسیلې کمره نه لري. د مخ په واسطه حاضري پرې ممکنه نه ده.", + "face_check_in": "د مخ په واسطه حاضري", + "face_enrol": "د مخ ثبت", + "tab_work": "کار", + "tab_leave": "رخصتي", + "tab_pay": "معاش", + "leave_title": "رخصتي", + "leave_balances": "د رخصتۍ بیلانس", + "leave_no_balances": "ستاسو لپاره د رخصتۍ بیلانس نه دی ثبت شوی", + "leave_my_requests": "زما غوښتنې", + "leave_none_yet": "لا مو غوښتنه نه ده کړې", + "leave_days_left": "پاتې ورځې", + "leave_used": "کارول شوې", + "leave_pending_days": "په انتظار", + "leave_days": "ورځې", + "leave_apply": "د رخصتۍ غوښتنه", + "leave_type": "د رخصتۍ ډول", + "leave_from": "له", + "leave_to": "تر", + "leave_reason": "دلیل", + "leave_reason_hint": "ولې رخصتي غواړئ؟", + "leave_send": "لېږل", + "leave_cancel": "غوښتنه لغوه کړئ", + "leave_pending": "په انتظار", + "leave_approved": "تایید شو", + "leave_rejected": "رد شو", + "leave_cancelled": "لغوه شو", + "leave_no_balance": "ستاسو د رخصتۍ بیلانس د دومره ورځو لپاره بس نه دی", + "pay_title": "د معاش فیش", + "pay_none": "د دې کال لپاره فیش نشته", + "pay_net": "خالص ورکړه", + "pay_gross": "ټول ناخالص", + "pay_earnings": "عواید", + "pay_deductions": "کسرونه", + "pay_income_tax": "د معاش مالیه", + "pay_basic": "اساسي معاش", + "pay_absence": "د غیرحاضرۍ کسر", + "leave_type_annual": "کلنۍ رخصتي", + "leave_type_sick": "د ناروغۍ رخصتي", + "pay_total_deductions": "د کسرونو ټولګه", + "pay_days": "ورځې", + "pay_worked_days": "کاري ورځې", + "pay_paid_leave": "له معاش سره رخصتي", + "pay_lop": "بې‌معاشه غیرحاضري", + "pay_employer_cost": "د شرکت برخه", + "pay_employer_cost_note": "دا پیسې شرکت جلا ورکوي او ستاسو له معاش څخه نه دي کم شوې.", + "tab_history": "حاضري", + "hist_title": "زما حاضري", + "hist_none": "د دې مودې لپاره حاضري نه ده ثبت شوې", + "hist_present": "حاضر", + "hist_absent": "غیرحاضر", + "hist_on_leave": "رخصت", + "hist_holiday": "رسمي رخصتي", + "hist_weekend": "د اونۍ رخصتي", + "hist_half_day": "نیمه ورځ", + "hist_request_correction": "د سمون غوښتنه", + "hist_correction_pending": "ستاسو د سمون غوښتنه د تایید په انتظار ده", + "hist_fix_in": "د ننوتلو ساعت غلط دی", + "hist_fix_out": "د وتلو ساعت غلط دی", + "hist_in_time": "د ننوتلو ساعت", + "hist_out_time": "د وتلو ساعت", + "hist_correction_note": "یوازې هغه ساعت وټاکئ چې غلط دی. ستاسو سرپرست یې ګوري؛ اصلي حاضري نه پاکېږي.", + "hist_reason_hint": "څه پېښ شول؟ بېلګه: هېر مې کړل وتل ووهم", + "hist_pending": "په محاسبه کې", + "common_cancel": "لغوه", + "tab_ann": "اعلانونه", + "ann_title": "د شرکت اعلانونه", + "ann_none": "اوس مهال اعلان نشته", + "ann_important": "مهم", + "ann_urgent": "بیړني", + "tab_profile": "پروفایل", + "profile_language": "ژبه", + "profile_sync": "د لېږلو حالت", + "profile_all_sent": "هر څه لېږل شوي", + "profile_pending": "په موبایل کې خوندي، لا نه دي لېږل شوي", + "profile_pending_note": "د انټرنټ په وصلېدو به پخپله ولېږل شي. تر هغه پورې له حسابه مه وځئ.", + "profile_sign_out_confirm": "له حسابه وځئ؟", + "profile_sign_out_pending": "پر دې موبایل نالېږل شوې حاضري شته. که ووځئ، پاکېږي.", + "profile_version": "نسخه", + "lock_title": "د پرانیستلو لپاره خپله پېژندنه تایید کړئ", + "lock_unlock": "پرانیستل", + "lock_passcode": "د وسیلې پټنوم", + "lock_reason": "د ورک‌ټرک پرانیستلو لپاره", + "lock_setting": "د اپ کولپ", + "lock_note": "هر ځل چې اپ پرانیزئ، پوښتل کیږي. ستاسو نالېږل شوې حاضري نه ګډوډیږي.", + "lock_unavailable": "دې موبایل کولپ نه لري. لومړی په تنظیماتو کې پټنوم کېږدئ.", + "ann_all": "ټول اعلانونه", + "punch_in": "د ننوتلو ثبت", + "punch_out": "د وتلو ثبت", + "punching": "په ثبتولو کې…", + "punch_state_in": "ستاسو ننوتل ثبت دي", + "punch_state_out": "لا مو حاضري نه ده وهلې", + "punch_first_in": "لومړی ننوتل", + "punch_worked": "کار", + "punch_ok_in": "ننوتل ثبت شو", + "punch_ok_out": "وتل ثبت شو", + "punch_flagged": "ستاسو حاضري ونه شمېرل شوه — د کاري ساحې څخه بهر وئ. ثبت شوه او سرپرست یې ویني؛ که تېروتنه وي، د سمون غوښتنه وکړئ.", + "punch_outside": "له ساحې بهر", + "punch_inside": "په ساحه کې", + "punch_distance": "تر ساحې واټن", + "punch_meters": "متره", + "punch_kilometres": "کیلومتره", + "punch_no_fences": "د دې شرکت لپاره کاري ساحه نه ده ټاکل شوې", + "err_location_denied": "د موقعیت اجازه نه ده ورکړل شوې. له تنظیماتو یې فعال کړئ.", + "err_location_unavailable": "موقعیت ونه موندل شو. د خلاص آسمان لاندې بیا هڅه وکړئ.", + "hours_minutes": "ساعته او دقیقې", + "retry": "بیا", + ], + .english: [ + "app_name": "WorkTrack", + "sign_in_title": "Welcome", + "sign_in_subtitle": "Workforce management for Afghan businesses", + "email": "Work email", + "password": "Password", + "sign_in": "Sign in", + "signing_in": "Signing in…", + "sign_out": "Sign out", + "err_bad_credentials": "That email or password is not right", + "err_offline": "You are not connected", + "err_generic": "Something went wrong", + "work_title": "Your work", + "work_today": "Today", + "work_next": "Next working day", + "work_none_today": "Nothing assigned to you today. Ask your supervisor if that seems wrong.", + "work_none_next": "Nothing assigned for that day yet.", + "work_weekend": "This is the weekly day off.", + "work_holiday": "This is a public holiday.", + "work_team": "Team job", + "work_solo": "Individual", + "work_with": "With", + "work_start": "Started", + "work_finish": "Finished", + "status_planned": "Planned", + "status_in_progress": "In progress", + "status_done": "Done", + "status_blocked": "Blocked", + "chip_present": "In", + "chip_away": "Out", + "offline_now": "No connection — showing what was saved", + "offline_stale": "Updating…", + "offline_pending": "Check-in saved on this phone; it will send when you have signal", + "offline_updated": "Updated", + "offline_minutes": "min ago", + "offline_hours": "h ago", + "punch_queued": "Recorded and saved on this phone. It will send itself when you have signal.", + "punch_expired": "Some check-ins were too old to send. Ask your supervisor to correct the day.", + "common_close": "Close", + "face_enrol_prompt": "Hold your face in the oval and press the button. No photo is saved — only a numeric code.", + "face_verify_prompt": "Look at the camera and press the button", + "face_checking": "Checking…", + "face_not_recognised": "Not recognised. Come a little closer and try again in better light.", + "face_no_face": "No face found", + "face_many_faces": "More than one face in the picture", + "face_too_far": "Too far away. Come closer.", + "face_needs_connection": "Face check needs a connection. Check in with GPS instead.", + "face_already_enrolled": "Your face is already enrolled", + "face_camera_denied": "Camera permission is off. Turn it on in Settings.", + "face_camera_unavailable": "This device has no camera, so face check-in is not possible on it.", + "face_check_in": "Check in with face", + "face_enrol": "Enrol your face", + "tab_work": "Work", + "tab_leave": "Leave", + "tab_pay": "Pay", + "leave_title": "Leave", + "leave_balances": "Leave balances", + "leave_no_balances": "No leave balance has been set for you", + "leave_my_requests": "My requests", + "leave_none_yet": "You have not asked for leave yet", + "leave_days_left": "days left", + "leave_used": "Used", + "leave_pending_days": "Pending", + "leave_days": "days", + "leave_apply": "Request leave", + "leave_type": "Leave type", + "leave_from": "From", + "leave_to": "To", + "leave_reason": "Reason", + "leave_reason_hint": "Why do you need leave?", + "leave_send": "Send", + "leave_cancel": "Withdraw request", + "leave_pending": "Pending", + "leave_approved": "Approved", + "leave_rejected": "Rejected", + "leave_cancelled": "Withdrawn", + "leave_no_balance": "You do not have enough leave for that many days", + "pay_title": "Payslips", + "pay_none": "No payslips for this year", + "pay_net": "Net pay", + "pay_gross": "Gross", + "pay_earnings": "Earnings", + "pay_deductions": "Deductions", + "pay_income_tax": "Income tax", + "pay_basic": "Basic salary", + "pay_absence": "Absence deduction", + "leave_type_annual": "Annual leave", + "leave_type_sick": "Sick leave", + "pay_total_deductions": "Total deductions", + "pay_days": "Days", + "pay_worked_days": "Days worked", + "pay_paid_leave": "Paid leave", + "pay_lop": "Unpaid absence", + "pay_employer_cost": "Employer contribution", + "pay_employer_cost_note": "The company pays this separately. It was not taken from your pay.", + "tab_history": "Attendance", + "hist_title": "My attendance", + "hist_none": "No attendance recorded for this period", + "hist_present": "Present", + "hist_absent": "Absent", + "hist_on_leave": "On leave", + "hist_holiday": "Public holiday", + "hist_weekend": "Weekend", + "hist_half_day": "Half day", + "hist_request_correction": "Request a correction", + "hist_correction_pending": "Your correction request is waiting for approval", + "hist_fix_in": "The check-in time is wrong", + "hist_fix_out": "The check-out time is wrong", + "hist_in_time": "Check-in time", + "hist_out_time": "Check-out time", + "hist_correction_note": "Only pick the time that is actually wrong. Your supervisor reviews it; the original record is not erased.", + "hist_reason_hint": "What happened? e.g. I forgot to check out", + "hist_pending": "Not settled yet", + "common_cancel": "Cancel", + "tab_ann": "Notices", + "ann_title": "Company notices", + "ann_none": "No notices right now", + "ann_important": "Important", + "ann_urgent": "Urgent", + "tab_profile": "Profile", + "profile_language": "Language", + "profile_sync": "Sending", + "profile_all_sent": "Everything has been sent", + "profile_pending": "Saved on this phone, not sent yet", + "profile_pending_note": "It sends itself as soon as you have signal. Do not sign out before then.", + "profile_sign_out_confirm": "Sign out?", + "profile_sign_out_pending": "There is a check-in on this phone that has not been sent. Signing out erases it.", + "profile_version": "Version", + "lock_title": "Unlock to continue", + "lock_unlock": "Unlock", + "lock_passcode": "device passcode", + "lock_reason": "to open WorkTrack", + "lock_setting": "App lock", + "lock_note": "You will be asked every time you open the app. Anything waiting to be sent stays untouched.", + "lock_unavailable": "This phone has no lock. Set a passcode in Settings first.", + "ann_all": "All notices", + "punch_in": "Check in", + "punch_out": "Check out", + "punching": "Recording…", + "punch_state_in": "You are checked in", + "punch_state_out": "Not checked in yet", + "punch_first_in": "First in", + "punch_worked": "Worked", + "punch_ok_in": "Check-in recorded", + "punch_ok_out": "Check-out recorded", + "punch_flagged": "This did not count as attendance — you were outside the work site. It is recorded and your supervisor will see it; request a correction if that is wrong.", + "punch_outside": "Outside the site", + "punch_inside": "On site", + "punch_distance": "Distance to site", + "punch_meters": "m", + "punch_kilometres": "km", + "punch_no_fences": "This company has no work site set", + "err_location_denied": "Location permission is off. Turn it on in Settings.", + "err_location_unavailable": "Could not get a location. Try again under open sky.", + "hours_minutes": "h and min", + "retry": "Try again", + ], + ] +} diff --git a/ios/WorkTrack/Core/OfflineBanner.swift b/ios/WorkTrack/Core/OfflineBanner.swift new file mode 100644 index 0000000..6a67118 --- /dev/null +++ b/ios/WorkTrack/Core/OfflineBanner.swift @@ -0,0 +1,42 @@ +import SwiftUI + +/// Says plainly what the phone is holding and how old what you are reading is. +/// +/// The alternative — a silent app that looks normal — is what makes somebody +/// believe a punch went through when it is sitting in a queue. +struct OfflineBanner: View { + let isOnline: Bool + let pending: Int + let fetchedAt: Date? + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: isOnline ? "arrow.triangle.2.circlepath" : "wifi.slash") + VStack(alignment: .leading, spacing: 2) { + Text(headline).fontWeight(.medium) + if let age = ageText { + Text(age).font(.caption2).foregroundStyle(.secondary) + } + } + Spacer() + } + .font(.caption) + .foregroundStyle(pending > 0 ? Palette.warning : Palette.neutral) + .padding(.vertical, 4) + } + + private var headline: String { + if pending > 0 { + return "\(L.t("offline_pending")) (\(L.n(pending)))" + } + return isOnline ? L.t("offline_stale") : L.t("offline_now") + } + + private var ageText: String? { + guard let fetchedAt else { return nil } + let minutes = Int(Date().timeIntervalSince(fetchedAt) / 60) + if minutes < 1 { return nil } + if minutes < 60 { return "\(L.t("offline_updated")) \(L.n(minutes)) \(L.t("offline_minutes"))" } + return "\(L.t("offline_updated")) \(L.n(minutes / 60)) \(L.t("offline_hours"))" + } +} diff --git a/ios/WorkTrack/Core/OfflineStore.swift b/ios/WorkTrack/Core/OfflineStore.swift new file mode 100644 index 0000000..46f8705 --- /dev/null +++ b/ios/WorkTrack/Core/OfflineStore.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Small, durable, dependency-free storage on disk. +/// +/// Not SwiftData (iOS 17, and this app targets 16 so it reaches an iPhone 8), +/// not Core Data, not a SQLite package. What this app has to keep is a handful +/// of queued punches and one day's plan — JSON in Application Support is the +/// right size for that, and it is the format a person can read when they are +/// trying to work out what a phone in Kabul is holding. +/// +/// Application Support rather than Caches, because the system evicts Caches +/// under disk pressure and an unsent punch is somebody's pay. +struct OfflineStore { + private let directory: URL + private let fileManager = FileManager.default + + init(directory: URL? = nil) { + if let directory { + self.directory = directory + } else { + let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + self.directory = base.appendingPathComponent("WorkTrack", isDirectory: true) + } + try? fileManager.createDirectory(at: self.directory, withIntermediateDirectories: true) + // Nothing here should ride to iCloud and land on another handset: a + // queued punch belongs to this device's session, not to the account. + var url = self.directory + var values = URLResourceValues() + values.isExcludedFromBackup = true + try? url.setResourceValues(values) + } + + func load(_ type: T.Type, from name: String) -> T? { + guard let data = try? Data(contentsOf: url(name)) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + func save(_ value: some Encodable, to name: String) { + guard let data = try? JSONEncoder().encode(value) else { return } + // Atomic: a phone that dies mid-write must not leave half a queue. + try? data.write(to: url(name), options: .atomic) + } + + func remove(_ name: String) { + try? fileManager.removeItem(at: url(name)) + } + + private func url(_ name: String) -> URL { + directory.appendingPathComponent("\(name).json") + } +} diff --git a/ios/WorkTrack/Core/Reachability.swift b/ios/WorkTrack/Core/Reachability.swift new file mode 100644 index 0000000..1b518cf --- /dev/null +++ b/ios/WorkTrack/Core/Reachability.swift @@ -0,0 +1,35 @@ +import Combine +import Network + +/// Whether there is a path to the network right now. +/// +/// Used to decide whether to try at all and to drain the queue the moment +/// signal comes back — not to decide whether a punch is allowed. A punch is +/// always allowed; the only question is whether it goes now or later. +@MainActor +final class Reachability: ObservableObject { + @Published private(set) var isOnline = true + + private let monitor = NWPathMonitor() + private var onRestored: (() -> Void)? + + init() { + monitor.pathUpdateHandler = { [weak self] path in + Task { @MainActor in + guard let self else { return } + let nowOnline = path.status == .satisfied + let wasOffline = !self.isOnline + self.isOnline = nowOnline + if nowOnline && wasOffline { self.onRestored?() } + } + } + monitor.start(queue: DispatchQueue(label: "app.worktrack.reachability")) + } + + /// Called when the network comes back after being away. + func whenRestored(_ action: @escaping () -> Void) { + onRestored = action + } + + deinit { monitor.cancel() } +} diff --git a/ios/WorkTrack/Core/ULID.swift b/ios/WorkTrack/Core/ULID.swift new file mode 100644 index 0000000..c978819 --- /dev/null +++ b/ios/WorkTrack/Core/ULID.swift @@ -0,0 +1,32 @@ +import Foundation + +/// A client-generated ULID: 10 characters of timestamp, 16 of randomness, +/// Crockford base32, 26 characters total. +/// +/// The id is made HERE, not by the server, and that is the whole point: it is +/// what makes a punch idempotent. A phone that sends the same punch twice — +/// because the reply was lost, or the queue replayed — writes the same document +/// twice, which is once. The server validates the length at 26 +/// (punchCreateSchema), and being time-ordered means punches sort by when they +/// happened even before anything parses a date out of them. +enum ULID { + private static let alphabet = Array("0123456789ABCDEFGHJKMNPQRSTVWXYZ") + + static func generate(at date: Date = Date()) -> String { + var out = "" + var ms = UInt64(date.timeIntervalSince1970 * 1000) + + // 10 characters of milliseconds, most significant first. + var timeChars = [Character](repeating: "0", count: 10) + for i in stride(from: 9, through: 0, by: -1) { + timeChars[i] = alphabet[Int(ms % 32)] + ms /= 32 + } + out.append(contentsOf: timeChars) + + for _ in 0..<16 { + out.append(alphabet[Int.random(in: 0..<32)]) + } + return out + } +} diff --git a/ios/WorkTrack/Core/WorkCache.swift b/ios/WorkTrack/Core/WorkCache.swift new file mode 100644 index 0000000..c9c6783 --- /dev/null +++ b/ios/WorkTrack/Core/WorkCache.swift @@ -0,0 +1,39 @@ +import Foundation + +/// The last plan and attendance the server gave us, kept so the app opens with +/// something to show on a site with no signal. +/// +/// A stale plan shown WITH its age beats an empty screen: a worker who sees +/// yesterday's job knows where he was going, and knows to check. A spinner +/// tells him nothing. +struct CachedDay: Codable, Equatable { + let fetchedAt: Date + let work: MyWork? + let attendance: AttendanceDay? + let fences: [Geofence] +} + +/// Not actor-isolated: it owns no mutable state, only a path on disk. +final class WorkCache: @unchecked Sendable { + private let store: OfflineStore + private let fileName = "day-cache" + + init(store: OfflineStore = OfflineStore()) { + self.store = store + } + + func load() -> CachedDay? { + store.load(CachedDay.self, from: fileName) + } + + func save(work: MyWork?, attendance: AttendanceDay?, fences: [Geofence]) { + store.save( + CachedDay(fetchedAt: Date(), work: work, attendance: attendance, fences: fences), + to: fileName + ) + } + + func clear() { + store.remove(fileName) + } +} diff --git a/ios/WorkTrack/Face/CameraController.swift b/ios/WorkTrack/Face/CameraController.swift new file mode 100644 index 0000000..1b42521 --- /dev/null +++ b/ios/WorkTrack/Face/CameraController.swift @@ -0,0 +1,100 @@ +import AVFoundation +import UIKit + +/// The front camera, open only while the check-in screen is on screen. +/// +/// A live capture session, never a photo library. Letting somebody choose an +/// existing image would let one worker check another one in by photographing a +/// photograph — the whole point of face attendance is that the person is +/// standing there. The simulator has no camera and therefore cannot do face +/// check-in at all; see FaceCaptureView for how that is handled without +/// opening the same hole. +@MainActor +final class CameraController: NSObject, ObservableObject { + enum State: Equatable { + case idle + case denied + case unavailable + case running + } + + @Published private(set) var state: State = .idle + + let session = AVCaptureSession() + private let output = AVCapturePhotoOutput() + private var pending: CheckedContinuation? + + enum Failure: Error, Equatable { + case notRunning + case captureFailed + } + + /// Asks for the camera and starts the preview. + func start() async { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: break + case .notDetermined: + guard await AVCaptureDevice.requestAccess(for: .video) else { + state = .denied + return + } + default: + state = .denied + return + } + + guard + let device = AVCaptureDevice.default( + .builtInWideAngleCamera, for: .video, position: .front + ), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input), session.canAddOutput(output) + else { + // No front camera: every simulator, and a handful of odd devices. + state = .unavailable + return + } + + session.beginConfiguration() + session.sessionPreset = .photo + session.addInput(input) + session.addOutput(output) + session.commitConfiguration() + + // startRunning blocks; keeping it off the main thread stops the UI + // hitching while the camera warms up. + await Task.detached { [session] in session.startRunning() }.value + state = .running + } + + func stop() { + guard session.isRunning else { return } + Task.detached { [session] in session.stopRunning() } + } + + func capture() async throws -> UIImage { + guard state == .running else { throw Failure.notRunning } + return try await withCheckedThrowingContinuation { continuation in + pending = continuation + output.capturePhoto(with: AVCapturePhotoSettings(), delegate: self) + } + } +} + +extension CameraController: AVCapturePhotoCaptureDelegate { + nonisolated func photoOutput( + _ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error? + ) { + let image = photo.fileDataRepresentation().flatMap(UIImage.init(data:)) + Task { @MainActor in + if let image { + pending?.resume(returning: image) + } else { + pending?.resume(throwing: Failure.captureFailed) + } + pending = nil + } + } +} diff --git a/ios/WorkTrack/Face/CameraPreview.swift b/ios/WorkTrack/Face/CameraPreview.swift new file mode 100644 index 0000000..3f0ad64 --- /dev/null +++ b/ios/WorkTrack/Face/CameraPreview.swift @@ -0,0 +1,25 @@ +import AVFoundation +import SwiftUI + +/// The live camera, as a SwiftUI view. +struct CameraPreview: UIViewRepresentable { + let session: AVCaptureSession + + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + view.videoPreviewLayer.session = session + view.videoPreviewLayer.videoGravity = .resizeAspectFill + return view + } + + func updateUIView(_ uiView: PreviewView, context: Context) {} + + /// A UIView whose backing layer IS the preview layer, so it resizes with + /// the view instead of needing a frame kept in sync by hand. + final class PreviewView: UIView { + override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } + var videoPreviewLayer: AVCaptureVideoPreviewLayer { + layer as! AVCaptureVideoPreviewLayer + } + } +} diff --git a/ios/WorkTrack/Face/FaceCaptureView.swift b/ios/WorkTrack/Face/FaceCaptureView.swift new file mode 100644 index 0000000..9d18ae8 --- /dev/null +++ b/ios/WorkTrack/Face/FaceCaptureView.swift @@ -0,0 +1,166 @@ +import SwiftUI + +/// Check in with your face, or enrol it the first time. +/// +/// One screen for both, because they are the same act from the worker's side: +/// look at the phone. The difference is what the server does with the vector. +struct FaceCaptureView: View { + enum Purpose: Identifiable { + case enrol, verify + var id: Self { self } + } + + let purpose: Purpose + let service: FaceService + /// Called with the server's token once a face is verified; the punch that + /// follows presents it as proof. + let onVerified: (String) -> Void + let onEnrolled: () -> Void + + @Environment(\.dismiss) private var dismiss + @StateObject private var camera = CameraController() + @State private var status: Status = .aiming + @State private var isWorking = false + + private enum Status: Equatable { + case aiming + case checking + /// Not a match, with how close it was — "come closer" is actionable, + /// "not recognised" is not. + case notRecognised(similarity: Double) + case problem(String) + } + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + switch camera.state { + case .running: + CameraPreview(session: camera.session).ignoresSafeArea() + overlay + case .denied: + message(L.t("face_camera_denied")) + case .unavailable: + // The simulator, mostly. Deliberately NOT falling back to the + // photo library: choosing an existing image would let one + // worker check another in by photographing a photograph, and a + // convenience for testing is not worth a hole in attendance. + message(L.t("face_camera_unavailable")) + case .idle: + ProgressView().tint(.white) + } + } + .task { await camera.start() } + .onDisappear { camera.stop() } + } + + private var overlay: some View { + VStack { + HStack { + Button(L.t("common_close")) { dismiss() } + .foregroundStyle(.white) + .padding() + Spacer() + } + Spacer() + + // A guide, not a crop: the detector finds the face wherever it is. + // This is only to get somebody to hold the phone at arm's length + // and face it, which is what makes the embedding stable. + Ellipse() + .strokeBorder(guideColor, lineWidth: 3) + .frame(width: 240, height: 320) + + Spacer() + Text(prompt) + .font(.callout) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + .padding(.bottom, 12) + + Button { + Task { await run() } + } label: { + ZStack { + Circle().fill(.white).frame(width: 74, height: 74) + if isWorking { ProgressView() } + } + } + .disabled(isWorking) + .padding(.bottom, 40) + } + } + + private var guideColor: Color { + switch status { + case .aiming, .checking: return .white + case .notRecognised, .problem: return Palette.accent + } + } + + private var prompt: String { + switch status { + case .aiming: + return L.t(purpose == .enrol ? "face_enrol_prompt" : "face_verify_prompt") + case .checking: + return L.t("face_checking") + case .notRecognised: + // The similarity is deliberately NOT shown. To a worker it is a + // number he cannot act on, and to anyone else it is a hint about + // how close somebody else's face is. + return L.t("face_not_recognised") + case .problem(let message): + return message + } + } + + private func run() async { + isWorking = true + status = .checking + defer { isWorking = false } + + do { + let photo = try await camera.capture() + switch purpose { + case .enrol: + _ = try await service.enrol(photo) + onEnrolled() + dismiss() + case .verify: + let result = try await service.verify(photo) + if let token = result.token, result.match { + onVerified(token) + dismiss() + } else { + status = .notRecognised(similarity: result.similarity) + } + } + } catch FaceDetector.Failure.noFace { + status = .problem(L.t("face_no_face")) + } catch FaceDetector.Failure.tooManyFaces { + status = .problem(L.t("face_many_faces")) + } catch FaceDetector.Failure.tooSmall { + status = .problem(L.t("face_too_far")) + } catch ApiError.offline { + // Face check needs the server: the enrolled vector lives there and + // the token is signed there. Nothing to queue. + status = .problem(L.t("face_needs_connection")) + } catch ApiError.problem(_, let code, _) where code == "FACE_ALREADY_ENROLLED" { + status = .problem(L.t("face_already_enrolled")) + } catch { + status = .problem(L.t("err_generic")) + } + } + + private func message(_ text: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "camera.fill").font(.largeTitle) + Text(text).multilineTextAlignment(.center) + Button(L.t("common_close")) { dismiss() }.fontWeight(.semibold) + } + .foregroundStyle(.white) + .padding(32) + } +} diff --git a/ios/WorkTrack/Face/FaceDetector.swift b/ios/WorkTrack/Face/FaceDetector.swift new file mode 100644 index 0000000..00b0c37 --- /dev/null +++ b/ios/WorkTrack/Face/FaceDetector.swift @@ -0,0 +1,54 @@ +import UIKit +import Vision + +/// Finds the face in a camera frame and crops it the way the model expects. +/// +/// Vision replaces ML Kit here — both give a face bounding box, and the box is +/// all that is used. What must NOT differ from Android is what happens to that +/// box afterwards, so the crop rules live in one place and are tested. +enum FaceDetector { + enum Failure: Error, Equatable { + case noFace + case tooManyFaces + case tooSmall + } + + /// A face smaller than this fraction of the frame is too far away to embed + /// reliably; better to ask the worker to come closer than to enrol a blur. + static let minimumFaceFraction: CGFloat = 0.08 + + static func crop(from image: UIImage) async throws -> UIImage { + guard let cgImage = image.cgImage else { throw Failure.noFace } + + let request = VNDetectFaceRectanglesRequest() + let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up) + try handler.perform([request]) + + let faces = request.results ?? [] + guard !faces.isEmpty else { throw Failure.noFace } + // Two faces means we cannot know whose attendance this is. + guard faces.count == 1 else { throw Failure.tooManyFaces } + + let face = faces[0] + guard face.boundingBox.width >= minimumFaceFraction else { throw Failure.tooSmall } + + let rect = pixelRect(face.boundingBox, in: cgImage) + guard let cropped = cgImage.cropping(to: rect) else { throw Failure.noFace } + return UIImage(cgImage: cropped) + } + + /// Vision reports a normalised box with the origin at the BOTTOM-left; + /// CoreGraphics images are indexed from the top. Getting this flip wrong + /// crops the forehead instead of the face — and still returns an image, so + /// nothing errors and the embedding is simply of the wrong thing. + static func pixelRect(_ boundingBox: CGRect, in image: CGImage) -> CGRect { + let width = CGFloat(image.width) + let height = CGFloat(image.height) + return CGRect( + x: boundingBox.minX * width, + y: (1 - boundingBox.maxY) * height, + width: boundingBox.width * width, + height: boundingBox.height * height + ).integral + } +} diff --git a/ios/WorkTrack/Face/FaceEmbedder.swift b/ios/WorkTrack/Face/FaceEmbedder.swift new file mode 100644 index 0000000..a19cebf --- /dev/null +++ b/ios/WorkTrack/Face/FaceEmbedder.swift @@ -0,0 +1,62 @@ +import Foundation +import TensorFlowLite +import UIKit + +/// On-device face embedding with the MobileFaceNet TFLite model. +/// +/// The SAME model file as the Android app — copied from +/// feature/attendance/src/main/assets/mobilefacenet.tflite — run through the +/// same interpreter. That is not a convenience: identity matching is cosine +/// similarity on the server against whatever vector the enrolling phone +/// produced, so the two clients must land in one vector space or an employee +/// who enrolled on Android is simply not recognised here. +/// +/// Only the numbers ever leave the device. No photo is uploaded or stored. +enum FaceEmbedderError: Error, Equatable { + /// The model asset is missing from the bundle. + case modelUnavailable + /// The image could not be turned into the model's input. + case badInput + case inferenceFailed +} + +final class FaceEmbedder { + private let interpreter: Interpreter + /// Read from the model rather than hard-coded, so a 128-d or 192-d + /// MobileFaceNet both work — as on Android. + let embeddingSize: Int + + init() throws { + guard let path = Bundle.main.path(forResource: "mobilefacenet", ofType: "tflite") else { + throw FaceEmbedderError.modelUnavailable + } + do { + interpreter = try Interpreter(modelPath: path) + try interpreter.allocateTensors() + let output = try interpreter.output(at: 0) + embeddingSize = output.shape.dimensions.last ?? 192 + } catch { + throw FaceEmbedderError.modelUnavailable + } + } + + /// A cropped face → an L2-normalised embedding, ready to send. + func embed(_ face: UIImage) throws -> [Float] { + guard let input = FacePreprocessor.inputBuffer(from: face) else { + throw FaceEmbedderError.badInput + } + do { + try input.withUnsafeBufferPointer { buffer in + try interpreter.copy(Data(buffer: buffer), toInputAt: 0) + } + try interpreter.invoke() + let output = try interpreter.output(at: 0) + let values = output.data.withUnsafeBytes { raw in + Array(raw.bindMemory(to: Float.self)) + } + return FacePreprocessor.l2Normalise(values) + } catch { + throw FaceEmbedderError.inferenceFailed + } + } +} diff --git a/ios/WorkTrack/Face/FacePreprocessor.swift b/ios/WorkTrack/Face/FacePreprocessor.swift new file mode 100644 index 0000000..a7548ce --- /dev/null +++ b/ios/WorkTrack/Face/FacePreprocessor.swift @@ -0,0 +1,85 @@ +import CoreGraphics +import Foundation +import UIKit + +/// Turning a cropped face into the exact float buffer MobileFaceNet expects. +/// +/// This file is the whole risk of the feature. The model runs on Android too, +/// and the server compares the two vectors with cosine similarity against a +/// fixed 0.6 threshold — so an employee who enrolled on Android has to produce +/// a matching vector here. Get any of this subtly wrong and nothing errors: +/// the model still runs, still returns 192 plausible numbers, and the +/// similarity just quietly falls under the threshold. The worker is standing +/// at the gate and the app does not know him. +/// +/// The contract, copied from FaceEmbedder.kt and pinned by tests: +/// +/// input 112 × 112 +/// channels R, G, B — in that order +/// normalise (channel − 127.5) / 128 +/// layout float32, interleaved per pixel, row-major +/// output 192 floats, L2-normalised +enum FacePreprocessor { + static let inputSize = 112 + static let channels = 3 + + /// A face bitmap → the model's input buffer. + /// + /// Resized with the same "scale to the target box" the Android side uses + /// (`Bitmap.createScaledBitmap`), NOT an aspect-preserving fit: changing + /// the geometry would move the face inside the frame relative to every + /// embedding already enrolled. + static func inputBuffer(from image: UIImage) -> [Float]? { + guard let pixels = rgbaPixels(from: image, side: inputSize) else { return nil } + + var out = [Float]() + out.reserveCapacity(inputSize * inputSize * channels) + for i in stride(from: 0, to: pixels.count, by: 4) { + // RGBA source, and only R, G, B are taken — alpha is not an input. + out.append(normalise(pixels[i])) + out.append(normalise(pixels[i + 1])) + out.append(normalise(pixels[i + 2])) + } + return out + } + + /// MobileFaceNet's normalisation. Not /255, and not mean-subtraction — + /// either would produce a valid-looking vector in the wrong space. + static func normalise(_ channel: UInt8) -> Float { + (Float(channel) - 127.5) / 128 + } + + /// L2 normalisation, applied to the model's output before it is sent. + /// + /// The server's cosine similarity divides by the magnitudes anyway, so this + /// does not change the comparison — but the Android client normalises + /// before sending, and the vectors are stored as sent. Keeping both clients + /// identical means a stored embedding is the same thing whichever phone + /// wrote it. + static func l2Normalise(_ vector: [Float]) -> [Float] { + let magnitude = sqrt(vector.reduce(0) { $0 + $1 * $1 }) + guard magnitude > 0 else { return vector } + return vector.map { $0 / magnitude } + } + + /// Draws the image into a square RGBA byte buffer of `side` × `side`. + private static func rgbaPixels(from image: UIImage, side: Int) -> [UInt8]? { + guard let cgImage = image.cgImage else { return nil } + var pixels = [UInt8](repeating: 0, count: side * side * 4) + guard let context = CGContext( + data: &pixels, + width: side, + height: side, + bitsPerComponent: 8, + bytesPerRow: side * 4, + space: CGColorSpaceCreateDeviceRGB(), + // Alpha last, and NOT premultiplied: premultiplying would scale the + // colour channels by alpha and shift every value the model sees. + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { return nil } + + context.interpolationQuality = .high + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: side, height: side)) + return pixels + } +} diff --git a/ios/WorkTrack/Face/FaceService.swift b/ios/WorkTrack/Face/FaceService.swift new file mode 100644 index 0000000..4ce8870 --- /dev/null +++ b/ios/WorkTrack/Face/FaceService.swift @@ -0,0 +1,63 @@ +import Foundation +import UIKit + +/// Enrolling a face, and verifying one at check-in. +/// +/// Only the embedding crosses the wire. The photo never leaves the phone, is +/// never written to disk, and is not held after the vector is computed. +/// +/// Verification is a TWO-STEP handshake and that is deliberate on the server's +/// side: /attendance/face/verify returns a short-lived signed token, and the +/// punch that follows must present it. The client cannot assert "this was +/// face-verified" on its own — `faceVerified` is derived from the token +/// server-side and is never read from the request body. +@MainActor +final class FaceService { + struct VerifyResult: Decodable { + let match: Bool + let similarity: Double + let threshold: Double + /// Present only on a match; the punch carries it as proof. + let token: String? + } + + private struct EnrolResult: Decodable { + let enrolled: Bool? + } + + private let client: ApiClient + private let embedder: FaceEmbedder + + init(client: ApiClient, embedder: FaceEmbedder) { + self.client = client + self.embedder = embedder + } + + /// Enrol the caller's own face. The server refuses a second enrolment + /// (FACE_ALREADY_ENROLLED) rather than overwriting one. + func enrol(_ photo: UIImage) async throws -> Bool { + let embedding = try await embedding(from: photo) + let result: EnrolResult = try await client.post( + "me/face/enroll", body: ["embedding": embedding] + ) + return result.enrolled ?? true + } + + /// Check a face against the enrolled one. A near-miss is reported with its + /// similarity so the UI can say "come closer" rather than "not you". + func verify(_ photo: UIImage) async throws -> VerifyResult { + let embedding = try await embedding(from: photo) + return try await client.post( + "attendance/face/verify", body: ["embedding": embedding] + ) + } + + /// Crop, embed, and hand back plain numbers. The UIImage goes out of scope + /// here and is never retained. + private func embedding(from photo: UIImage) async throws -> [Double] { + let face = try await FaceDetector.crop(from: photo) + // Doubles because JSONSerialization writes Float as a Double anyway, + // and the server compares in double precision. + return try embedder.embed(face).map(Double.init) + } +} diff --git a/ios/WorkTrack/Face/mobilefacenet.tflite b/ios/WorkTrack/Face/mobilefacenet.tflite new file mode 100644 index 0000000..057b985 Binary files /dev/null and b/ios/WorkTrack/Face/mobilefacenet.tflite differ diff --git a/ios/WorkTrack/Info.plist b/ios/WorkTrack/Info.plist new file mode 100644 index 0000000..3b670fc --- /dev/null +++ b/ios/WorkTrack/Info.plist @@ -0,0 +1,42 @@ + + + + + CFBundleDevelopmentRegion + fa + CFBundleDisplayName + WorkTrack + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + fa + ps + en + + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + NSCameraUsageDescription + برای تایید حاضری با چهره، دوربین فقط در همان لحظه استفاده می‌شود. عکس شما جایی ذخیره یا ارسال نمی‌شود. + NSLocationWhenInUseUsageDescription + برای ثبت حاضری، موقعیت شما یک بار در همان لحظه خوانده می‌شود تا معلوم شود در ساحهٔ کاری هستید. + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/ios/WorkTrack/Leave/LeaveApplyView.swift b/ios/WorkTrack/Leave/LeaveApplyView.swift new file mode 100644 index 0000000..fd4079e --- /dev/null +++ b/ios/WorkTrack/Leave/LeaveApplyView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// Asking for leave. +struct LeaveApplyView: View { + @ObservedObject var model: LeaveViewModel + let types: [LeaveType] + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var app: AppState + @State private var typeId = "" + @State private var from = Date() + @State private var to = Date() + @State private var reason = "" + + var body: some View { + NavigationStack { + Form { + Section { + Picker(L.t("leave_type"), selection: $typeId) { + ForEach(types) { type in Text(type.displayName).tag(type.id) } + } + DatePicker( + L.t("leave_from"), selection: $from, displayedComponents: .date + ) + DatePicker( + // Never before the start: the server rejects it, and + // being told so after typing a reason is a wasted trip. + L.t("leave_to"), selection: $to, in: from..., displayedComponents: .date + ) + } footer: { + Text(shamsiRange).font(.caption) + } + + Section(L.t("leave_reason")) { + TextField(L.t("leave_reason_hint"), text: $reason, axis: .vertical) + .lineLimit(3...6) + } + + if let error = model.submitError { + Section { Text(error).foregroundStyle(Palette.negative).font(.subheadline) } + } + } + .navigationTitle(L.t("leave_apply")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L.t("common_cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(L.t("leave_send")) { + Task { + if await model.apply( + typeId: typeId, from: iso(from), to: iso(to), + reason: reason.trimmingCharacters(in: .whitespacesAndNewlines) + ) { dismiss() } + } + } + .fontWeight(.semibold) + .disabled(!canSubmit) + } + } + .onAppear { if typeId.isEmpty { typeId = types.first?.id ?? "" } } + } + } + + private var canSubmit: Bool { + !typeId.isEmpty + && !reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !model.isSubmitting + } + + /// The dates in the calendar people actually use, under the pickers — + /// which show Gregorian, because that is what iOS gives. + private var shamsiRange: String { + let start = AfghanCalendar.format(from, language: app.language) + let end = AfghanCalendar.format(to, language: app.language) + return start == end ? start : "\(start) – \(end)" + } + + private func iso(_ date: Date) -> String { + let f = DateFormatter() + f.calendar = Calendar(identifier: .gregorian) + f.locale = Locale(identifier: "en_US_POSIX") + // The company's day: a request made late at night from another + // timezone must not land on yesterday. + f.timeZone = TimeZone(identifier: "Asia/Kabul") + f.dateFormat = "yyyy-MM-dd" + return f.string(from: date) + } +} diff --git a/ios/WorkTrack/Leave/LeaveModels.swift b/ios/WorkTrack/Leave/LeaveModels.swift new file mode 100644 index 0000000..481dd23 --- /dev/null +++ b/ios/WorkTrack/Leave/LeaveModels.swift @@ -0,0 +1,76 @@ +import Foundation + +struct LeaveType: Codable, Identifiable, Equatable { + let id: String + let name: String + let code: String + let colorHex: String? + let isPaid: Bool? + + /// Only the untouched Dari names signup seeds are translated; a company's own name stays as typed. + var displayName: String { + switch (code, name) { + case ("ANNUAL", "رخصتی سالانه"): return L.t("leave_type_annual") + case ("SICK", "رخصتی مریضی"): return L.t("leave_type_sick") + default: return name + } + } +} + +/// One person's balance for one leave type, for one year. +/// +/// `periodYear` here is GREGORIAN (2026), unlike a payslip's, which is Solar +/// Hijri (1405). That is the server's shape, not a choice made here — so the +/// year is never shown to the worker on this screen; the days are what matter. +struct LeaveBalance: Codable, Identifiable, Equatable { + let id: String + let leaveTypeId: String + let periodYear: Int + let entitledDays: Double + let accruedDays: Double + let usedDays: Double + let carriedOverDays: Double + let pendingDays: Double + + /// What is actually left to take: entitlement plus carry-over, less what + /// has been used AND what is already waiting on a decision. Leaving + /// pending days out would let somebody book the same days twice. + var availableDays: Double { + entitledDays + carriedOverDays - usedDays - pendingDays + } +} + +enum LeaveStatus: String, Codable { + case pending = "PENDING" + case approved = "APPROVED" + case rejected = "REJECTED" + case cancelled = "CANCELLED" + + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = LeaveStatus(rawValue: raw) ?? .pending + } + + var label: String { + switch self { + case .pending: return L.t("leave_pending") + case .approved: return L.t("leave_approved") + case .rejected: return L.t("leave_rejected") + case .cancelled: return L.t("leave_cancelled") + } + } +} + +struct LeaveRequest: Codable, Identifiable, Equatable { + let id: String + let leaveTypeId: String + let startDate: String + let endDate: String + let days: Double + let reason: String + let status: LeaveStatus + let decisionNote: String? + + /// Only a request nobody has decided yet can be withdrawn. + var isCancellable: Bool { status == .pending } +} diff --git a/ios/WorkTrack/Leave/LeaveView.swift b/ios/WorkTrack/Leave/LeaveView.swift new file mode 100644 index 0000000..6bc186f --- /dev/null +++ b/ios/WorkTrack/Leave/LeaveView.swift @@ -0,0 +1,168 @@ +import SwiftUI + +/// Leave: what is left, what has been asked for, and asking for more. +struct LeaveView: View { + @EnvironmentObject private var app: AppState + @StateObject private var model: LeaveViewModel + @State private var applying = false + + init(client: ApiClient) { + _model = StateObject(wrappedValue: LeaveViewModel(client: client)) + } + + var body: some View { + NavigationStack { + Group { + switch model.state { + case .loading: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed(let message): + RetryState(message: message) { Task { await model.load() } } + case .loaded(let overview): + List { + Section(L.t("leave_balances")) { + if overview.balances.isEmpty { + Text(L.t("leave_no_balances")) + .font(.subheadline).foregroundStyle(.secondary) + } + ForEach(overview.balances) { balance in + balanceRow(balance, name: overview.typeName(balance.leaveTypeId)) + } + } + + Section(L.t("leave_my_requests")) { + if overview.requests.isEmpty { + Text(L.t("leave_none_yet")) + .font(.subheadline).foregroundStyle(.secondary) + } + ForEach(overview.requests) { request in + requestRow(request, name: overview.typeName(request.leaveTypeId)) + } + } + } + .listStyle(.insetGrouped) + .refreshable { await model.load() } + } + } + .navigationTitle(L.t("leave_title")) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(L.t("leave_apply")) { applying = true } + .fontWeight(.semibold) + } + } + .sheet(isPresented: $applying) { + if case .loaded(let overview) = model.state { + LeaveApplyView(model: model, types: overview.types) + } + } + } + .task { await model.load() } + } + + private func balanceRow(_ balance: LeaveBalance, name: String) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(name).font(.headline) + // Both halves of the sum, because "12 left" invites the + // question "out of what?" and a worker planning a trip needs + // to see that pending days are already spoken for. + // + // Separate Texts, NOT one interpolated string. With a neutral + // "·" between two numbers the bidi algorithm reorders them and + // the digits collide: used=2, pending=0 rendered as "۲۰ + // استفاده‌شده" — twenty days used, to anyone reading it. + HStack(spacing: 4) { + Text(L.t("leave_used")) + Text(L.n(days(balance.usedDays))).fontWeight(.medium) + Text("·") + Text(L.t("leave_pending_days")) + Text(L.n(days(balance.pendingDays))).fontWeight(.medium) + } + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + VStack(alignment: .trailing, spacing: 0) { + Text(L.n(days(balance.availableDays))) + .font(.title3).fontWeight(.semibold) + .foregroundStyle(balance.availableDays > 0 ? Palette.deep : Palette.negative) + Text(L.t("leave_days_left")).font(.caption2).foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + private func requestRow(_ request: LeaveRequest, name: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(name).font(.headline) + Spacer() + Pill(text: request.status.label, tone: tone(request.status)) + } + // Same reason as above: the day count sat against the year and + // read as one number. + HStack(spacing: 6) { + Text(dateRange(request)) + Text("·") + Text("\(L.n(days(request.days))) \(L.t("leave_days"))") + } + .font(.subheadline).foregroundStyle(.secondary) + if !request.reason.isEmpty { + Text(request.reason).font(.caption) + } + if let note = request.decisionNote, !note.isEmpty { + Text(note).font(.caption).foregroundStyle(Palette.warning) + } + if request.isCancellable { + Button(L.t("leave_cancel"), role: .destructive) { + Task { await model.cancel(request) } + } + .font(.caption) + } + } + .padding(.vertical, 4) + } + + private func dateRange(_ request: LeaveRequest) -> String { + let from = AfghanCalendar.parseISODate(request.startDate) + let to = AfghanCalendar.parseISODate(request.endDate) + guard let from, let to else { return "" } + if request.startDate == request.endDate { + return AfghanCalendar.format(from, language: app.language) + } + return "\(AfghanCalendar.format(from, language: app.language, withYear: false)) – \(AfghanCalendar.format(to, language: app.language))" + } + + /// Half days are real (startHalfDay/endHalfDay), so 2.5 must not render + /// as "2" or as "2.50". + private func days(_ value: Double) -> String { + value == value.rounded() + ? String(Int(value)) + : String(format: "%.1f", value) + } + + private func tone(_ status: LeaveStatus) -> Color { + switch status { + case .approved: return Palette.positive + case .pending: return Palette.warning + case .rejected: return Palette.negative + case .cancelled: return Palette.neutral + } + } +} + +/// Shared empty/error state with a retry. +struct RetryState: View { + let message: String + let retry: () -> Void + + var body: some View { + VStack(spacing: 12) { + Text(message).foregroundStyle(.secondary).multilineTextAlignment(.center) + Button(L.t("retry"), action: retry) + .fontWeight(.semibold).foregroundStyle(Palette.deep) + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/ios/WorkTrack/Leave/LeaveViewModel.swift b/ios/WorkTrack/Leave/LeaveViewModel.swift new file mode 100644 index 0000000..f8f8ff9 --- /dev/null +++ b/ios/WorkTrack/Leave/LeaveViewModel.swift @@ -0,0 +1,100 @@ +import Foundation + +/// The employee's own leave: what is left, what is asked for, and asking. +@MainActor +final class LeaveViewModel: ObservableObject { + struct Overview: Equatable { + let balances: [LeaveBalance] + let requests: [LeaveRequest] + let types: [LeaveType] + + func typeName(_ id: String) -> String { + types.first { $0.id == id }?.displayName ?? id + } + } + + enum State: Equatable { + case loading + case loaded(Overview) + case failed(String) + } + + @Published private(set) var state: State = .loading + @Published private(set) var isSubmitting = false + @Published var submitError: String? + + private let client: ApiClient + + init(client: ApiClient) { + self.client = client + } + + func load() async { + do { + // Three reads, run together: the balances carry only a type id, so + // the names have to come from somewhere. + async let balances: [LeaveBalance] = client.get("leave/balances") + async let requests: [LeaveRequest] = client.get( + "leave/requests", query: ["scope": "mine"] + ) + async let types: [LeaveType] = client.get("leave/types") + + state = .loaded(Overview( + balances: try await balances, + requests: try await requests.sorted { $0.startDate > $1.startDate }, + types: try await types + )) + } catch ApiError.offline { + state = .failed(L.t("err_offline")) + } catch { + state = .failed(L.t("err_generic")) + } + } + + /// Ask for leave. Online only: a request the approver cannot see is not a + /// request, and queuing one would tell the worker it was filed when nobody + /// has it. + func apply(typeId: String, from: String, to: String, reason: String) async -> Bool { + isSubmitting = true + submitError = nil + defer { isSubmitting = false } + do { + let _: LeaveRequest = try await client.post( + "leave/requests", + body: [ + // Client-generated, like a punch: a resend is the same + // request, not a second one. + "id": ULID.generate(), + "leaveTypeId": typeId, + "startDate": from, + "endDate": to, + "reason": reason, + ] + ) + await load() + return true + } catch ApiError.offline { + submitError = L.t("err_offline") + } catch ApiError.problem(_, let code, let detail) { + // The server refuses an overlapping request or one with no balance + // left; both are things the worker can act on, so show its words. + submitError = code == "INSUFFICIENT_LEAVE_BALANCE" + ? L.t("leave_no_balance") : detail + } catch { + submitError = L.t("err_generic") + } + return false + } + + func cancel(_ request: LeaveRequest) async { + guard request.isCancellable else { return } + do { + let _: LeaveRequest = try await client.post( + "leave/requests/\(request.id)/cancel", body: [:] + ) + await load() + } catch { + submitError = L.t("err_generic") + } + } +} diff --git a/ios/WorkTrack/Payslips/PayslipModels.swift b/ios/WorkTrack/Payslips/PayslipModels.swift new file mode 100644 index 0000000..b326330 --- /dev/null +++ b/ios/WorkTrack/Payslips/PayslipModels.swift @@ -0,0 +1,74 @@ +import Foundation + +/// A payslip line is one of THREE things, not two. +/// +/// EMPLOYER_COST is what the company pays on top — pension, for instance. It +/// is not taken from the worker, and treating anything-not-EARNING as a +/// deduction put it in the wrong column: the deductions then did not add up to +/// the total, which on a payslip reads as having been underpaid. +enum PayslipLineType: String, Codable { + case earning = "EARNING" + case deduction = "DEDUCTION" + case employerCost = "EMPLOYER_COST" + + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + // An unknown type is NOT assumed to be a deduction — inventing a + // deduction is the one direction that must never happen by accident. + self = PayslipLineType(rawValue: raw) ?? .earning + } +} + +struct PayslipLine: Codable, Identifiable, Equatable { + let componentCode: String + let componentName: String + let type: PayslipLineType + let amount: Double + + var id: String { componentCode } + var isEarning: Bool { type == .earning } + + /// Payroll names these three lines in Dari itself; company components keep their own names. + var displayName: String { + switch componentCode { + case "BASIC": return L.t("pay_basic") + case "LOP": return L.t("pay_absence") + case "TAX": return L.t("pay_income_tax") + default: return componentName + } + } +} + +/// One month's pay. +/// +/// `periodYear` is a SOLAR HIJRI year — payroll writes 1405, not 2026 — and the +/// endpoint validates the range, so asking with a Gregorian year returns +/// nothing at all. The comment in routes/payslips.ts records that this had +/// already been got wrong once. +struct Payslip: Codable, Identifiable, Equatable { + let id: String + let periodYear: Int + let periodMonth: Int + let currency: String + let gross: Double + let totalDeductions: Double + let net: Double + let incomeTax: Double + let workedDays: Double? + let paidLeaveDays: Double? + let lopDays: Double? + let status: String + let lines: [PayslipLine]? + + var earnings: [PayslipLine] { lines(of: .earning) } + /// Only real deductions. These sum to `totalDeductions`, and a test holds + /// that against the server's own figure. + var deductions: [PayslipLine] { lines(of: .deduction) } + /// What the company pays on top. Shown, because it is part of what the job + /// costs and workers ask — but never mixed into what was taken from them. + var employerCosts: [PayslipLine] { lines(of: .employerCost) } + + private func lines(of type: PayslipLineType) -> [PayslipLine] { + (lines ?? []).filter { $0.type == type } + } +} diff --git a/ios/WorkTrack/Payslips/PayslipsView.swift b/ios/WorkTrack/Payslips/PayslipsView.swift new file mode 100644 index 0000000..c3545ec --- /dev/null +++ b/ios/WorkTrack/Payslips/PayslipsView.swift @@ -0,0 +1,142 @@ +import SwiftUI + +/// Payslips, month by month. +struct PayslipsView: View { + @EnvironmentObject private var app: AppState + @StateObject private var model: PayslipsViewModel + + init(client: ApiClient) { + _model = StateObject(wrappedValue: PayslipsViewModel(client: client)) + } + + var body: some View { + NavigationStack { + Group { + switch model.state { + case .loading: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed(let message): + RetryState(message: message) { Task { await model.load() } } + case .loaded(let slips): + if slips.isEmpty { + RetryState(message: L.t("pay_none")) { Task { await model.load() } } + } else { + List(slips) { slip in + NavigationLink { + PayslipDetailView(slip: slip) + } label: { + row(slip) + } + } + .listStyle(.insetGrouped) + .refreshable { await model.load() } + } + } + } + .navigationTitle(L.t("pay_title")) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + // Solar Hijri years, because that is what the payslip is + // filed under. + Menu(L.n(model.year)) { + ForEach((model.year - 3...model.year).reversed(), id: \.self) { year in + Button(L.n(year)) { Task { await model.show(year: year) } } + } + } + } + } + } + .task { await model.load() } + } + + private func row(_ slip: Payslip) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(AfghanCalendar.monthName(slip.periodMonth, language: app.language)) + .font(.headline) + Text(L.n(slip.periodYear)).font(.caption).foregroundStyle(.secondary) + } + Spacer() + // The net figure, because that is the number a worker is looking + // for — the gross is on the detail screen. + Text(AfghanCalendar.money(slip.net, currency: slip.currency, language: app.language)) + .font(.callout).fontWeight(.semibold) + } + .padding(.vertical, 4) + } +} + +struct PayslipDetailView: View { + let slip: Payslip + @EnvironmentObject private var app: AppState + + var body: some View { + List { + Section { + amount(L.t("pay_net"), slip.net, emphasised: true) + } header: { + Text("\(AfghanCalendar.monthName(slip.periodMonth, language: app.language)) \(L.n(slip.periodYear))") + } + + Section(L.t("pay_earnings")) { + ForEach(slip.earnings) { line in + amount(line.displayName, line.amount) + } + amount(L.t("pay_gross"), slip.gross, emphasised: true) + } + + Section(L.t("pay_deductions")) { + // The server already sends income tax as one of these lines; + // adding it again from `incomeTax` listed it twice and made the + // column stop adding up. + ForEach(slip.deductions) { line in + amount(line.displayName, line.amount) + } + amount(L.t("pay_total_deductions"), slip.totalDeductions, emphasised: true) + } + + if !slip.employerCosts.isEmpty { + Section { + ForEach(slip.employerCosts) { line in + amount(line.displayName, line.amount) + } + } header: { + Text(L.t("pay_employer_cost")) + } footer: { + // Said plainly, because a number under a payslip that is + // not explained is assumed to have been taken from you. + Text(L.t("pay_employer_cost_note")) + } + } + + Section(L.t("pay_days")) { + if let worked = slip.workedDays { count(L.t("pay_worked_days"), worked) } + if let paid = slip.paidLeaveDays, paid > 0 { count(L.t("pay_paid_leave"), paid) } + // Unpaid absence, named plainly. This is the line that makes + // somebody come and ask, and they are entitled to. + if let lop = slip.lopDays, lop > 0 { count(L.t("pay_lop"), lop) } + } + } + .navigationTitle(L.t("pay_title")) + .navigationBarTitleDisplayMode(.inline) + } + + private func amount(_ label: String, _ value: Double, emphasised: Bool = false) -> some View { + HStack { + Text(label).fontWeight(emphasised ? .semibold : .regular) + Spacer() + Text(AfghanCalendar.money(value, currency: slip.currency, language: app.language)) + .fontWeight(emphasised ? .semibold : .regular) + .foregroundStyle(emphasised ? Palette.deep : .primary) + } + } + + private func count(_ label: String, _ value: Double) -> some View { + HStack { + Text(label) + Spacer() + Text(L.n(value == value.rounded() ? String(Int(value)) : String(format: "%.1f", value))) + .foregroundStyle(.secondary) + } + } +} diff --git a/ios/WorkTrack/Payslips/PayslipsViewModel.swift b/ios/WorkTrack/Payslips/PayslipsViewModel.swift new file mode 100644 index 0000000..f1d98c0 --- /dev/null +++ b/ios/WorkTrack/Payslips/PayslipsViewModel.swift @@ -0,0 +1,44 @@ +import Foundation + +/// The employee's own payslips, month by month. +@MainActor +final class PayslipsViewModel: ObservableObject { + enum State: Equatable { + case loading + case loaded([Payslip]) + case failed(String) + } + + @Published private(set) var state: State = .loading + @Published private(set) var year: Int + + private let client: ApiClient + + init(client: ApiClient) { + self.client = client + // The CURRENT Solar Hijri year, not the Gregorian one. Sending 2026 + // here returns an empty list from a perfectly healthy server, which is + // the sort of bug that reads as "I have never been paid". + year = AfghanCalendar.currentShamsiYear() + } + + func load() async { + do { + let slips: [Payslip] = try await client.get( + "payslips", query: ["year": String(year)] + ) + // Newest month first: the one somebody opens the app for. + state = .loaded(slips.sorted { $0.periodMonth > $1.periodMonth }) + } catch ApiError.offline { + state = .failed(L.t("err_offline")) + } catch { + state = .failed(L.t("err_generic")) + } + } + + func show(year newYear: Int) async { + year = newYear + state = .loading + await load() + } +} diff --git a/ios/WorkTrack/Profile/AppLock.swift b/ios/WorkTrack/Profile/AppLock.swift new file mode 100644 index 0000000..a0d5c8e --- /dev/null +++ b/ios/WorkTrack/Profile/AppLock.swift @@ -0,0 +1,78 @@ +import Foundation +import LocalAuthentication + +/// Face ID or the passcode, gating local access to the app. +/// +/// The same idea as BiometricLockScreen on Android: the session underneath +/// stays valid, this only decides whether the person holding the phone may see +/// it. That distinction matters — a lock that signed you out would discard the +/// queued punches, and a worker on a shared phone would lose a morning's work +/// to a privacy setting. +/// +/// Off by default. A worker with his own handset does not need it, and a +/// biometric prompt on every launch is the kind of thing that gets an app +/// deleted. +@MainActor +final class AppLock: ObservableObject { + /// True while the app is locked and must show nothing behind it. + @Published private(set) var isLocked = false + @Published private(set) var isEnabled: Bool + + /// The phone can do it at all — no Face ID and no passcode means no lock. + let isAvailable: Bool + /// What the device actually offers, so the button can name it. + let biometryName: String + + private static let key = "worktrack.applock" + + init() { + let context = LAContext() + var error: NSError? + isAvailable = context.canEvaluatePolicy( + .deviceOwnerAuthentication, error: &error + ) + switch context.biometryType { + case .faceID: biometryName = "Face ID" + case .touchID: biometryName = "Touch ID" + default: biometryName = L.t("lock_passcode") + } + + let stored = UserDefaults.standard.bool(forKey: Self.key) + // If the phone lost its passcode, a stored preference would lock + // somebody out of their own attendance with no way back in. + isEnabled = stored && isAvailable + isLocked = isEnabled + } + + func setEnabled(_ enabled: Bool) { + isEnabled = enabled && isAvailable + UserDefaults.standard.set(isEnabled, forKey: Self.key) + } + + /// Called when the app comes back to the foreground. + func lockIfNeeded() { + if isEnabled { isLocked = true } + } + + /// Prompts, and unlocks only on success. + func unlock() async { + guard isEnabled else { + isLocked = false + return + } + let context = LAContext() + do { + // deviceOwnerAuthentication, not …WithBiometrics: a worker whose + // face is not recognised — dust, a mask, a scarred hand — must + // still be able to fall back to the passcode rather than being + // shut out of his own attendance. + let ok = try await context.evaluatePolicy( + .deviceOwnerAuthentication, localizedReason: L.t("lock_reason") + ) + isLocked = !ok + } catch { + // Cancelled or failed: stay locked, and let them try again. + isLocked = true + } + } +} diff --git a/ios/WorkTrack/Profile/AppLockScreen.swift b/ios/WorkTrack/Profile/AppLockScreen.swift new file mode 100644 index 0000000..c304f0a --- /dev/null +++ b/ios/WorkTrack/Profile/AppLockScreen.swift @@ -0,0 +1,26 @@ +import SwiftUI + +/// What is shown while the app is locked. Nothing of the worker's is behind it. +struct AppLockScreen: View { + @ObservedObject var lock: AppLock + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "lock.fill") + .font(.system(size: 44)) + .foregroundStyle(Palette.deep) + Text(L.t("lock_title")).font(.headline) + Button(L.t("lock_unlock")) { + Task { await lock.unlock() } + } + .fontWeight(.semibold) + .foregroundStyle(.white) + .padding(.horizontal, 28).padding(.vertical, 12) + .background(Palette.deep, in: Capsule()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemBackground)) + // The prompt fires by itself, so the usual case is one glance and in. + .task { await lock.unlock() } + } +} diff --git a/ios/WorkTrack/Profile/ProfileView.swift b/ios/WorkTrack/Profile/ProfileView.swift new file mode 100644 index 0000000..8c91d4d --- /dev/null +++ b/ios/WorkTrack/Profile/ProfileView.swift @@ -0,0 +1,111 @@ +import SwiftUI + +/// Who you are, how the app is set, and the way out. +/// +/// The language switch lived in a menu behind an ellipsis on one tab, which is +/// the wrong place for the setting a worker is most likely to need on day one — +/// a Pashto speaker handed a Dari app has to find it before anything else makes +/// sense. +struct ProfileView: View { + @EnvironmentObject private var auth: AuthStore + @EnvironmentObject private var app: AppState + @ObservedObject var attendance: AttendanceViewModel + @ObservedObject var lock: AppLock + @State private var confirmingSignOut = false + + var body: some View { + NavigationStack { + List { + if case .signedIn(let me) = auth.state { + Section { + VStack(alignment: .leading, spacing: 4) { + Text(me.displayName).font(.headline) + Text(me.companyName).font(.subheadline).foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + + Section(L.t("profile_language")) { + // A plain picker, not a menu: three options, and the one a + // worker needs first. + Picker(L.t("profile_language"), selection: languageBinding) { + ForEach(Language.allCases, id: \.self) { language in + Text(language.label).tag(language) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + + Section { + if attendance.pendingCount > 0 { + // The one piece of state a worker has a right to see: + // whether the phone is still holding something of his. + Label { + Text("\(L.t("profile_pending")) (\(L.n(attendance.pendingCount)))") + } icon: { + Image(systemName: "tray.and.arrow.up.fill") + } + .foregroundStyle(Palette.warning) + } else { + Label(L.t("profile_all_sent"), systemImage: "checkmark.circle.fill") + .foregroundStyle(Palette.positive) + } + } header: { + Text(L.t("profile_sync")) + } footer: { + if attendance.pendingCount > 0 { + Text(L.t("profile_pending_note")) + } + } + + Section { + Toggle(L.t("lock_setting"), isOn: lockBinding) + .disabled(!lock.isAvailable) + } footer: { + Text(lock.isAvailable ? L.t("lock_note") : L.t("lock_unavailable")) + } + + Section { + Button(L.t("sign_out"), role: .destructive) { confirmingSignOut = true } + } footer: { + Text("\(L.t("profile_version")) \(L.n(Self.version))") + } + } + .navigationTitle(L.t("tab_profile")) + .confirmationDialog( + // Signing out clears the queue and the cached day, so it is + // worth one question — especially with an unsent punch on the + // phone. + signOutPrompt, + isPresented: $confirmingSignOut, + titleVisibility: .visible + ) { + Button(L.t("sign_out"), role: .destructive) { auth.signOut() } + Button(L.t("common_cancel"), role: .cancel) {} + } + } + } + + private var signOutPrompt: String { + attendance.pendingCount > 0 + ? L.t("profile_sign_out_pending") + : L.t("profile_sign_out_confirm") + } + + private var lockBinding: Binding { + Binding(get: { lock.isEnabled }, set: { lock.setEnabled($0) }) + } + + private var languageBinding: Binding { + Binding(get: { app.language }, set: { app.setLanguage($0) }) + } + + private static var version: String { + let info = Bundle.main.infoDictionary + let short = info?["CFBundleShortVersionString"] as? String ?? "0" + let build = info?["CFBundleVersion"] as? String ?? "0" + return "\(short) (\(build))" + } +} diff --git a/ios/WorkTrack/Work/MyWorkView.swift b/ios/WorkTrack/Work/MyWorkView.swift new file mode 100644 index 0000000..bc4cbbb --- /dev/null +++ b/ios/WorkTrack/Work/MyWorkView.swift @@ -0,0 +1,251 @@ +import SwiftUI + +/// What this employee is on today, and on their next working day. +/// +/// The reason the app exists for a worker: he opens it on the way in and knows +/// which part of the job he is on before he walks to the wrong one. +struct MyWorkView: View { + @EnvironmentObject private var auth: AuthStore + @EnvironmentObject private var app: AppState + @StateObject private var model: WorkViewModel + @ObservedObject private var attendance: AttendanceViewModel + @StateObject private var reachability = Reachability() + @StateObject private var notices: AnnouncementsViewModel + + /// The company's today, from the server's own timezone — not the phone's. + private let todayISO: String + + /// The attendance model is owned by SignedInTabs and shared with the + /// profile screen; the cache file is shared too, because the plan and the + /// attendance day belong to the same day and must not overwrite each other. + init(client: ApiClient, attendance: AttendanceViewModel, cache: WorkCache) { + _model = StateObject(wrappedValue: WorkViewModel(client: client, cache: cache)) + _notices = StateObject(wrappedValue: AnnouncementsViewModel(client: client)) + self.attendance = attendance + let f = DateFormatter() + f.calendar = Calendar(identifier: .gregorian) + f.timeZone = TimeZone(identifier: "Asia/Kabul") + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + todayISO = f.string(from: Date()) + } + + var body: some View { + NavigationStack { + Group { + switch model.state { + case .loading: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed(let message): + VStack(spacing: 12) { + Text(message).foregroundStyle(.secondary).multilineTextAlignment(.center) + Button(L.t("retry")) { Task { await model.load() } } + .fontWeight(.semibold).foregroundStyle(Palette.deep) + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .loaded(let work): + List { + if !reachability.isOnline || attendance.pendingCount > 0 || model.isStale { + Section { OfflineBanner( + isOnline: reachability.isOnline, + pending: attendance.pendingCount, + fetchedAt: model.fetchedAt + ) } + } + Section { + PunchCard( + model: attendance, + todayISO: todayISO, + faceService: faceService, + hasEnrolledFace: me?.hasFace ?? false + ) + } + daySection(L.t("work_today"), work.today, isToday: true) + if let next = work.next { + daySection(L.t("work_next"), next, isToday: false) + } + noticesSection + } + .listStyle(.insetGrouped) + .refreshable { + await model.load() + await attendance.load(todayISO: todayISO, work: loadedWork) + await attendance.drain(todayISO: todayISO) + } + } + } + .navigationTitle(L.t("work_title")) + } + .task { + await notices.load() + await model.load() + await attendance.load(todayISO: todayISO, work: loadedWork) + // Anything the phone was holding goes now. + await attendance.drain(todayISO: todayISO) + reachability.whenRestored { + Task { await attendance.drain(todayISO: todayISO) } + } + } + } + + @ViewBuilder + private func daySection(_ label: String, _ day: WorkDay, isToday: Bool) -> some View { + Section { + if day.tasks.isEmpty { + // An empty day says why. A blank card reads as "the app is + // broken" or, worse, as "nothing to do" — and those are not + // the same thing. + Text(emptyMessage(for: day, isToday: isToday)) + .font(.subheadline).foregroundStyle(.secondary) + .padding(.vertical, 6) + } else { + ForEach(day.tasks) { task in + TaskRow( + task: task, + actionable: isToday, + isBusy: model.pendingTaskId == task.id, + onStatus: { status in Task { await model.setStatus(task, to: status) } } + ) + } + } + } header: { + HStack { + Text(label) + Spacer() + if let date = AfghanCalendar.parseISODate(day.date) { + Text(AfghanCalendar.format(date, language: app.language)) + .foregroundStyle(.secondary) + } + } + .font(.subheadline).textCase(nil) + } + } + + /// Company notices, under the day's work. + /// + /// Not a tab: iOS allows five, and a sixth is folded into a system "More" + /// menu that arrives in English and hides the screen behind an extra tap. + /// This is also where the Android dashboard puts them, and a worker reading + /// his day is the moment he will actually read a notice. + @ViewBuilder + private var noticesSection: some View { + if case .loaded(let items) = notices.state, !items.isEmpty { + Section(L.t("ann_title")) { + ForEach(items.prefix(3)) { announcement in + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top) { + Text(announcement.title).font(.subheadline).fontWeight(.medium) + Spacer() + if announcement.priority != .normal { + Pill( + text: announcement.priority.label, + tone: announcement.priority == .urgent + ? Palette.negative : Palette.warning + ) + } + } + Text(announcement.body) + .font(.caption).foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + if items.count > 3 { + NavigationLink(L.t("ann_all")) { + AnnouncementsList(items: items) + } + .font(.caption) + } + } + } + } + + private var me: Me? { + if case .signedIn(let me) = auth.state { return me } + return nil + } + + /// Built once, and only for a company that has face check-in switched on — + /// loading the model costs memory no other company should pay. + private var faceService: FaceService? { + guard me?.faceEnabled == true, let embedder = try? FaceEmbedder() else { return nil } + return FaceService(client: auth.client, embedder: embedder) + } + + private var loadedWork: MyWork? { + if case .loaded(let work) = model.state { return work } + return nil + } + + private func emptyMessage(for day: WorkDay, isToday: Bool) -> String { + switch day.kind { + case .weekend: return L.t("work_weekend") + case .holiday: return L.t("work_holiday") + case .working: return L.t(isToday ? "work_none_today" : "work_none_next") + } + } +} + +private struct TaskRow: View { + let task: WorkTask + let actionable: Bool + let isBusy: Bool + let onStatus: (TaskStatus) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text(task.title).font(.headline) + // Which part of the job, and where on the site. + Text([task.projectName, task.location].compactMap { $0 }.joined(separator: " — ")) + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Pill(text: task.status.label, tone: task.status.tone) + } + + if let detail = task.detail, !detail.isEmpty { + Text(detail).font(.subheadline) + } + + HStack(spacing: 8) { + Pill(text: task.isTeamWork ? L.t("work_team") : L.t("work_solo")) + if let team = task.teamName { + Text(team).font(.caption).foregroundStyle(.secondary) + } + } + + if task.isTeamWork { + // Two Texts rather than one interpolated string: with the label + // and the names in one run, the bidi algorithm pushes the colon + // to the far side and it reads ":همراه". + HStack(spacing: 4) { + Text(L.t("work_with")).fontWeight(.medium) + Text(task.assigneeNames.joined(separator: "، ")) + } + .font(.caption).foregroundStyle(.secondary) + } + + // Only today's work can be reported on: marking tomorrow's job + // finished today is never something the worker meant to do. + if actionable && task.status != .done { + HStack(spacing: 10) { + if task.status != .inProgress { + Button(L.t("work_start")) { onStatus(.inProgress) } + .buttonStyle(.bordered) + } + Button(L.t("work_finish")) { onStatus(.done) } + .buttonStyle(.borderedProminent) + .tint(Palette.deep) + } + .disabled(isBusy) + .opacity(isBusy ? 0.5 : 1) + // A List row swallows taps otherwise, firing whichever button + // the row thinks it owns. + .buttonStyle(.automatic) + } + } + .padding(.vertical, 6) + } +} diff --git a/ios/WorkTrack/Work/WorkModels.swift b/ios/WorkTrack/Work/WorkModels.swift new file mode 100644 index 0000000..2422f9c --- /dev/null +++ b/ios/WorkTrack/Work/WorkModels.swift @@ -0,0 +1,64 @@ +import Foundation + +enum TaskStatus: String, Codable { + case planned = "PLANNED" + case inProgress = "IN_PROGRESS" + case done = "DONE" + case blocked = "BLOCKED" + + /// Unknown values decode as PLANNED rather than failing the whole page: a + /// server that grows a fifth status must not blank an employee's day. + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = TaskStatus(rawValue: raw) ?? .planned + } + + var label: String { + switch self { + case .planned: return L.t("status_planned") + case .inProgress: return L.t("status_in_progress") + case .done: return L.t("status_done") + case .blocked: return L.t("status_blocked") + } + } +} + +enum DayKind: String, Codable { + case working = "WORKING" + case weekend = "WEEKEND" + case holiday = "HOLIDAY" + + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = DayKind(rawValue: raw) ?? .working + } +} + +struct WorkTask: Codable, Identifiable, Equatable { + let id: String + let projectName: String + let title: String + let detail: String? + let location: String? + let status: TaskStatus + let teamName: String? + let assigneeNames: [String] + + /// More than one name on it means a crew job, not a solo one. + var isTeamWork: Bool { assigneeNames.count > 1 } +} + +struct WorkDay: Codable, Equatable { + /// Plain `yyyy-MM-dd`; the server's calendar date, not a timestamp. + let date: String + let kind: DayKind + let tasks: [WorkTask] +} + +/// Today, and the next day the employee is actually expected in — which after +/// a Thursday is Saturday, because Friday is the weekend here. The server works +/// that out from the company's own calendar; the app just shows what it says. +struct MyWork: Codable, Equatable { + let today: WorkDay + let next: WorkDay? +} diff --git a/ios/WorkTrack/Work/WorkService.swift b/ios/WorkTrack/Work/WorkService.swift new file mode 100644 index 0000000..268dffa --- /dev/null +++ b/ios/WorkTrack/Work/WorkService.swift @@ -0,0 +1,82 @@ +import Foundation + +/// The employee's own work. +@MainActor +final class WorkViewModel: ObservableObject { + enum State: Equatable { + case loading + case loaded(MyWork) + case failed(String) + } + + @Published private(set) var state: State = .loading + /// Set while a status change is in flight, so a row can show it is busy. + @Published private(set) var pendingTaskId: String? + + /// True when what is on screen came off the disk, not the server. + @Published private(set) var isStale = false + /// When that cached copy was fetched, so the app can say how old it is. + @Published private(set) var fetchedAt: Date? + + private let client: ApiClient + private let cache: WorkCache + + init(client: ApiClient, cache: WorkCache = WorkCache()) { + self.client = client + self.cache = cache + // Open with the last plan rather than a spinner. A worker on a site + // with no signal still needs to know where he is going. + if let cached = cache.load(), let work = cached.work { + state = .loaded(work) + isStale = true + fetchedAt = cached.fetchedAt + } + } + + func load() async { + do { + // No date parameter: the server knows what day it is where the + // company is. A phone set to another timezone would ask about the + // wrong one. + let work: MyWork = try await client.get("work/mine") + state = .loaded(work) + isStale = false + fetchedAt = Date() + let existing = cache.load() + cache.save( + work: work, + attendance: existing?.attendance, + fences: existing?.fences ?? [] + ) + } catch ApiError.offline { + // Keep showing the cached plan rather than replacing it with an + // error: a stale answer beats no answer, as long as it says so. + if case .loaded = state { isStale = true } else { + state = .failed(L.t("err_offline")) + } + } catch { + if case .loaded = state { isStale = true } else { + state = .failed(L.t("err_generic")) + } + } + } + + /// Report progress. Online only — the Android outbox carries creations, and + /// there is no equivalent here yet, so a failure says so rather than + /// quietly showing a tick the foreman never saw. + func setStatus(_ task: WorkTask, to status: TaskStatus) async { + pendingTaskId = task.id + defer { pendingTaskId = nil } + do { + let _: WorkTask = try await client.post( + "work/tasks/\(task.id)/status", + body: ["status": status.rawValue] + ) + await load() + } catch ApiError.offline { + state = .failed(L.t("err_offline")) + } catch { + state = .failed(L.t("err_generic")) + } + } +} diff --git a/ios/WorkTrackTests/AfghanCalendarTests.swift b/ios/WorkTrackTests/AfghanCalendarTests.swift new file mode 100644 index 0000000..9b7174c --- /dev/null +++ b/ios/WorkTrackTests/AfghanCalendarTests.swift @@ -0,0 +1,146 @@ +import XCTest +@testable import WorkTrack + +/// The date and the digits. +/// +/// These are the parts a worker reads and acts on, and the parts that fail +/// silently: a calendar off by one still renders, and Latin digits in a Dari +/// sentence still render. Both would just be wrong. +final class AfghanCalendarTests: XCTestCase { + + /// Checked against WorkTrack's own implementation (web/src/shamsi/ + /// solarHijri.ts), which produced exactly these values for these dates. + func testAgreesWithTheRestOfTheProduct() { + let cases: [(String, String)] = [ + ("2026-09-08", "17 سنبله 1405"), + ("2026-03-21", "1 حمل 1405"), // Nowruz — the year turns + ("2026-03-20", "29 حوت 1404"), // the day before it + ("2027-01-01", "11 جدی 1405"), + ("2028-02-29", "10 حوت 1406"), // a Gregorian leap day + ] + for (iso, expected) in cases { + let date = AfghanCalendar.parseISODate(iso) + XCTAssertNotNil(date, "could not parse \(iso)") + // English keeps Latin digits, so this compares the arithmetic and + // the month index without the digit conversion in the way. + let formatted = AfghanCalendar.format(date!, language: .english) + let afghanMonth = expected.split(separator: " ")[1] + let day = expected.split(separator: " ")[0] + let year = expected.split(separator: " ")[2] + XCTAssertTrue( + formatted.hasPrefix("\(day) "), "\(iso): day wrong — got \(formatted)" + ) + XCTAssertTrue( + formatted.hasSuffix(" \(year)"), "\(iso): year wrong — got \(formatted)" + ) + _ = afghanMonth // month name checked in Dari below + } + } + + func testUsesAfghanMonthNamesNotIranianOnes() { + // The arithmetic is Foundation's Persian calendar, but Afghanistan + // calls the first month حمل where Iran calls it فروردین. Shipping the + // Iranian names would look foreign to every user. + let nowruz = AfghanCalendar.parseISODate("2026-03-21")! + let text = AfghanCalendar.format(nowruz, language: .dari) + XCTAssertTrue(text.contains("حمل"), "expected حمل, got \(text)") + XCTAssertFalse(text.contains("فروردین")) + } + + func testLocalizesDigitsForDariAndPashtoOnly() { + let date = AfghanCalendar.parseISODate("2026-09-08")! + XCTAssertEqual(AfghanCalendar.format(date, language: .dari), "۱۷ سنبله ۱۴۰۵") + XCTAssertTrue(AfghanCalendar.format(date, language: .pashto).contains("۱۴۰۵")) + XCTAssertEqual(AfghanCalendar.format(date, language: .english), "17 Sunbula 1405") + } + + func testReadsTheDateTheServerMeant() { + // A plain yyyy-MM-dd carries no zone. Reading it in the device's zone + // would shift the day for anyone west of UTC and show yesterday's work. + let date = AfghanCalendar.parseISODate("2026-09-08")! + XCTAssertEqual(AfghanCalendar.format(date, language: .english), "17 Sunbula 1405") + } + + func testEasternDigitsLeaveLatinTextAlone() { + XCTAssertEqual(AfghanCalendar.easternDigits("Block B 3"), "Block B ۳") + } +} + +/// How far away the worker is, in a form he can act on. +/// +/// The bug this pins down was found on the first real handset: the phone was +/// in Ottawa, the site in Kabul, and the card read "۱۰۴۵۷۲۲۰ متر" — eight +/// digits, no separators, no chance of reading it as ten thousand kilometres. +/// Printing raw metres quietly assumed the phone was near its site. +final class DistanceFormatTests: XCTestCase { + func testMetresWhileMetresAreWalkable() { + let d = AfghanCalendar.distance(meters: 340, language: .dari) + XCTAssertFalse(d.isKilometres) + XCTAssertEqual(d.value, "۳۴۰") + } + + func testJustUnderAKilometreIsStillMetres() { + // 999 m is a walk. 1000 m is where the unit turns over. + XCTAssertFalse(AfghanCalendar.distance(meters: 999, language: .dari).isKilometres) + XCTAssertTrue(AfghanCalendar.distance(meters: 1000, language: .dari).isKilometres) + } + + func testTheOttawaCase() { + // The exact number the first device run put on screen. + let d = AfghanCalendar.distance(meters: 10_457_220, language: .dari) + XCTAssertTrue(d.isKilometres) + // Grouped, and no decimal noise at this magnitude. + XCTAssertEqual(d.value, "۱۰٬۴۵۷") + // The failure being guarded against is a bare run of digits. + XCTAssertFalse(d.value.contains("۱۰۴۵۷"), "grouping separator was dropped") + } + + func testOneDecimalWhereItHelps() { + // At 1.4 km the fraction is the difference between a walk and a drive; + // at 10,457 km it is noise. Same formatter, different magnitudes. + XCTAssertEqual(AfghanCalendar.distance(meters: 1400, language: .dari).value, "۱٫۴") + XCTAssertEqual(AfghanCalendar.distance(meters: 12_000, language: .dari).value, "۱۲") + } + + func testEnglishKeepsLatinDigitsAndSeparators() { + // Arabic-Indic marks beside Latin digits read as a rendering fault — + // the same rule `money` follows. + let d = AfghanCalendar.distance(meters: 10_457_220, language: .english) + XCTAssertEqual(d.value, "10,457") + } +} + +/// The message a worker reads when the punch did not count. +/// +/// The server keeps an out-of-fence punch as evidence but excludes it from the +/// day's worked-time math, so the day stays empty and payroll now deducts for +/// unexcused absence. The first wording said "ثبت شد" — recorded — which is +/// true of the row in the database and false of the thing the worker cares +/// about. He would walk away believing he had checked in. +final class FlaggedPunchWordingTests: XCTestCase { + func testEveryLanguageSaysItDidNotCount() { + for language in [Language.dari, .pashto, .english] { + L.language = language + let text = L.t("punch_flagged") + XCTAssertFalse( + text.hasPrefix("ثبت شد") || text.hasPrefix("ثبت شو") + || text.hasPrefix("Recorded,"), + "\(language) leads with 'recorded', which reads as 'you are checked in'" + ) + } + L.language = .dari + } + + func testItPointsAtTheWayOut() { + // Telling somebody it did not count without telling him what to do + // leaves him standing there punching again. + for (language, needle) in [(Language.dari, "اصلاح"), (.pashto, "سمون"), (.english, "correction")] { + L.language = language + XCTAssertTrue( + L.t("punch_flagged").contains(needle), + "\(language) does not mention the correction request" + ) + } + L.language = .dari + } +} diff --git a/ios/WorkTrackTests/AnnouncementsTests.swift b/ios/WorkTrackTests/AnnouncementsTests.swift new file mode 100644 index 0000000..d853169 --- /dev/null +++ b/ios/WorkTrackTests/AnnouncementsTests.swift @@ -0,0 +1,62 @@ +import XCTest +@testable import WorkTrack + +@MainActor +final class AnnouncementsTests: XCTestCase { + + private var directory: URL! + + override func setUp() { + super.setUp() + directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: directory) + super.tearDown() + } + + private func decode(_ priority: String) throws -> Announcement { + let json = """ + {"id":"a1","title":"پرداخت معاش","body":"معاش این ماه واریز می‌شود.", + "priority":"\(priority)","publishedAt":"2026-09-08T16:57:09.202Z", + "expiresAt":null,"createdByName":"زهرا نوری"} + """ + return try JSONDecoder().decode(Announcement.self, from: Data(json.utf8)) + } + + func testDecodesTheServersAnnouncement() throws { + let announcement = try decode("NORMAL") + XCTAssertEqual(announcement.title, "پرداخت معاش") + XCTAssertEqual(announcement.createdByName, "زهرا نوری") + XCTAssertEqual(announcement.priority, .normal) + } + + func testAnUnknownPriorityIsNormal_neverUrgent() { + // A server that grows a fourth level must not start shouting at + // everybody; defaulting the other way would make every new notice + // arrive red. + XCTAssertEqual(try decode("SOMETHING_NEW").priority, .normal) + XCTAssertEqual(try decode("URGENT").priority, .urgent) + } + + func testNormalPriorityShowsNoBadgeText() { + // The chip is only drawn for the two that mean something; an empty + // label would render an empty pill. + XCTAssertTrue(try decode("NORMAL").priority.label.isEmpty) + XCTAssertFalse(try decode("URGENT").priority.label.isEmpty) + } + + func testReadStateSurvivesARestartAndStaysOnThisDevice() { + // Per-device on purpose: the server has no notion of read state, and + // inventing one would mean writing to the tenant every time somebody + // opens a tab. + let store = OfflineStore(directory: directory) + store.save(["a1", "a2"], to: "announcements-read") + + let reopened = OfflineStore(directory: directory) + .load([String].self, from: "announcements-read") + XCTAssertEqual(reopened, ["a1", "a2"]) + } +} diff --git a/ios/WorkTrackTests/AppLockTests.swift b/ios/WorkTrackTests/AppLockTests.swift new file mode 100644 index 0000000..6acb271 --- /dev/null +++ b/ios/WorkTrackTests/AppLockTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import WorkTrack + +/// The app lock. +/// +/// Every case here is about the same thing: a privacy setting must never cost +/// somebody their attendance. +@MainActor +final class AppLockTests: XCTestCase { + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: "worktrack.applock") + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: "worktrack.applock") + super.tearDown() + } + + func testOffByDefault() { + // A biometric prompt on every launch, unasked for, is the kind of thing + // that gets an app deleted. + let lock = AppLock() + XCTAssertFalse(lock.isEnabled) + XCTAssertFalse(lock.isLocked) + } + + func testEnablingPersists() { + AppLock().setEnabled(true) + // Only if the device can actually do it — a simulator without a + // passcode cannot, and the setting must not pretend otherwise. + let reopened = AppLock() + XCTAssertEqual(reopened.isEnabled, reopened.isAvailable) + } + + func testCannotBeEnabledOnAPhoneThatCannotLock() { + let lock = AppLock() + lock.setEnabled(true) + if !lock.isAvailable { + XCTAssertFalse(lock.isEnabled, "enabled on a device with no passcode") + } + } + + func testDisablingClearsIt() { + let lock = AppLock() + lock.setEnabled(true) + lock.setEnabled(false) + XCTAssertFalse(lock.isEnabled) + XCTAssertFalse(AppLock().isEnabled) + } + + func testLockingDoesNothingWhenTheSettingIsOff() { + let lock = AppLock() + lock.setEnabled(false) + lock.lockIfNeeded() + XCTAssertFalse(lock.isLocked) + } + + func testUnlockIsAPassThroughWhenDisabled() async { + // Otherwise turning the setting off would leave somebody staring at a + // lock screen they can no longer dismiss. + let lock = AppLock() + lock.setEnabled(false) + lock.lockIfNeeded() + await lock.unlock() + XCTAssertFalse(lock.isLocked) + } +} diff --git a/ios/WorkTrackTests/FaceGatingTests.swift b/ios/WorkTrackTests/FaceGatingTests.swift new file mode 100644 index 0000000..cdb8e43 --- /dev/null +++ b/ios/WorkTrackTests/FaceGatingTests.swift @@ -0,0 +1,61 @@ +import XCTest +@testable import WorkTrack + +/// Who is offered face check-in at all. +/// +/// Face recognition is OFF by default for a company (DEFAULT_SETTINGS on the +/// server), and it is the most invasive thing this app does. A button that +/// appears for a company that never asked for it is the failure to avoid. +final class FaceGatingTests: XCTestCase { + + private func me(face: Bool?, enrolled: Bool?) -> Me { + Me( + employeeId: "e1", companyId: "c1", displayName: "احمد کریمی", + companyName: "شرکت ساختمانی کابل", roles: ["EMPLOYEE"], + faceEnrolled: enrolled, + features: face.map { Me.Features(faceRecognition: $0) } + ) + } + + func testOffWhenTheCompanyHasNotEnabledIt() { + XCTAssertFalse(me(face: false, enrolled: false).faceEnabled) + } + + func testOffWhenTheServerSaysNothingAboutIt() { + // An older server, or a field that has not shipped yet: default to not + // offering it. Silence is not consent for a biometric feature. + XCTAssertFalse(me(face: nil, enrolled: nil).faceEnabled) + } + + func testOnOnlyWhenExplicitlyEnabled() { + XCTAssertTrue(me(face: true, enrolled: false).faceEnabled) + } + + func testEnrolmentIsSeparateFromTheFeatureBeingOn() { + // The screen shows "enrol" or "check in" based on this, so conflating + // the two would ask an enrolled worker to enrol again every morning. + XCTAssertFalse(me(face: true, enrolled: false).hasFace) + XCTAssertTrue(me(face: true, enrolled: true).hasFace) + XCTAssertFalse(me(face: true, enrolled: nil).hasFace) + } + + func testDecodesTheServersActualMeShape() throws { + // Trimmed from a real GET /v1/me response. + let json = """ + {"uid":"u1","companyId":"c1","companyName":"شرکت ساختمانی کابل", + "currency":"AFN","timezone":"Asia/Kabul","employeeId":"emp_ahmad", + "displayName":"احمد کریمی","email":"a@b.c","avatarUrl":null, + "roles":["EMPLOYEE"],"branchIds":["br_main"], + "features":{"shifts":true,"leave":true,"payroll":true, + "regularization":true,"announcements":true, + "geofencing":true,"qrKiosk":true,"faceRecognition":false, + "finance":true}, + "faceEnrolled":false} + """ + let decoded = try JSONDecoder().decode(Me.self, from: Data(json.utf8)) + + XCTAssertEqual(decoded.employeeId, "emp_ahmad") + XCTAssertFalse(decoded.faceEnabled, "the demo has face off, so no button") + XCTAssertFalse(decoded.hasFace) + } +} diff --git a/ios/WorkTrackTests/FacePipelineTests.swift b/ios/WorkTrackTests/FacePipelineTests.swift new file mode 100644 index 0000000..e79030f --- /dev/null +++ b/ios/WorkTrackTests/FacePipelineTests.swift @@ -0,0 +1,184 @@ +import XCTest +@testable import WorkTrack + +/// The face pipeline. +/// +/// Every one of these guards the same failure: the model runs, returns 192 +/// plausible numbers, and the cosine similarity quietly falls under the +/// server's 0.6 threshold — so an employee who enrolled on Android stops being +/// recognised, with no error anywhere. Nothing here is about crashes. +final class FacePipelineTests: XCTestCase { + + // MARK: preprocessing — the contract copied from FaceEmbedder.kt + + func testNormalisationMatchesTheAndroidFormula() { + // (channel − 127.5) / 128. NOT /255, and not mean-subtraction: both + // would produce a valid-looking vector in the wrong space. + XCTAssertEqual(FacePreprocessor.normalise(0), -127.5 / 128, accuracy: 1e-6) + XCTAssertEqual(FacePreprocessor.normalise(255), 127.5 / 128, accuracy: 1e-6) + XCTAssertEqual(FacePreprocessor.normalise(128), 0.5 / 128, accuracy: 1e-6) + } + + func testInputIsTheSizeAndShapeTheModelExpects() { + let image = solidImage(.init(red: 0.5, green: 0.5, blue: 0.5, alpha: 1), side: 200) + let buffer = FacePreprocessor.inputBuffer(from: image) + + XCTAssertEqual(buffer?.count, 112 * 112 * 3, "112×112×3 floats, whatever came in") + } + + func testChannelOrderIsRGB_notBGR() { + // The single most likely silent mistake. A pure red image must put the + // large value FIRST; BGR would put it third and every embedding would + // be of a different-coloured face. + let red = solidImage(.init(red: 1, green: 0, blue: 0, alpha: 1), side: 112) + guard let buffer = FacePreprocessor.inputBuffer(from: red) else { + return XCTFail("no buffer") + } + XCTAssertEqual(buffer[0], FacePreprocessor.normalise(255), accuracy: 0.02, "R") + XCTAssertEqual(buffer[1], FacePreprocessor.normalise(0), accuracy: 0.02, "G") + XCTAssertEqual(buffer[2], FacePreprocessor.normalise(0), accuracy: 0.02, "B") + } + + func testAlphaIsNotPremultiplied() { + // Premultiplying scales the colour channels by alpha and shifts every + // value the model sees. + let white = solidImage(.init(red: 1, green: 1, blue: 1, alpha: 1), side: 112) + guard let buffer = FacePreprocessor.inputBuffer(from: white) else { + return XCTFail("no buffer") + } + XCTAssertEqual(buffer[0], FacePreprocessor.normalise(255), accuracy: 0.02) + } + + func testL2NormalisationMakesAUnitVector() { + let normalised = FacePreprocessor.l2Normalise([3, 4]) + XCTAssertEqual(normalised[0], 0.6, accuracy: 1e-6) + XCTAssertEqual(normalised[1], 0.8, accuracy: 1e-6) + XCTAssertEqual(sqrt(normalised.reduce(0) { $0 + $1 * $1 }), 1, accuracy: 1e-6) + } + + func testL2OfAZeroVectorDoesNotDivideByZero() { + XCTAssertEqual(FacePreprocessor.l2Normalise([0, 0, 0]), [0, 0, 0]) + } + + // MARK: the crop + + func testVisionBoxIsFlippedToImageCoordinates() { + // Vision's origin is bottom-left, CoreGraphics' is top-left. Getting + // this wrong crops the forehead — and still returns an image, so + // nothing errors and the embedding is of the wrong thing. + let image = solidImage(.init(red: 0, green: 0, blue: 0, alpha: 1), side: 100).cgImage! + // Pixel dimensions, not points: a rendered UIImage is Retina-scaled, so + // the CGImage is 3x on this device. pixelRect works in pixels, which is + // what CGImage.cropping wants. + let pixelHeight = CGFloat(image.height) + + // A box in the TOP half as Vision sees it (maxY = 1.0). + let visionTopHalf = CGRect(x: 0, y: 0.5, width: 1, height: 0.5) + let rect = FaceDetector.pixelRect(visionTopHalf, in: image) + + XCTAssertEqual(rect.minY, 0, "a Vision box at the top must crop from y=0") + XCTAssertEqual(rect.height, pixelHeight / 2, accuracy: 1) + + // And the mirror case, which is what catches a missing flip: a box at + // the BOTTOM must crop from the bottom, not the top. + let visionBottomHalf = CGRect(x: 0, y: 0, width: 1, height: 0.5) + let bottom = FaceDetector.pixelRect(visionBottomHalf, in: image) + XCTAssertEqual(bottom.minY, pixelHeight / 2, accuracy: 1) + } + + // MARK: the model — the part that has to agree with Android + + func testTheModelLoadsAndProducesTheExpectedShape() throws { + let embedder = try FaceEmbedder() + XCTAssertTrue( + [128, 192].contains(embedder.embeddingSize), + "unexpected embedding size \(embedder.embeddingSize)" + ) + + let face = solidImage(.init(red: 0.6, green: 0.5, blue: 0.4, alpha: 1), side: 112) + let embedding = try embedder.embed(face) + + XCTAssertEqual(embedding.count, embedder.embeddingSize) + XCTAssertEqual(sqrt(embedding.reduce(0) { $0 + $1 * $1 }), 1, accuracy: 1e-4, + "what is sent must be L2-normalised, as on Android") + } + + func testTheSameFaceTwiceIsTheSameVector() throws { + // Determinism is the floor. If one phone cannot reproduce its own + // embedding, two phones certainly cannot. + let embedder = try FaceEmbedder() + let face = solidImage(.init(red: 0.6, green: 0.5, blue: 0.4, alpha: 1), side: 112) + + let a = try embedder.embed(face) + let b = try embedder.embed(face) + XCTAssertEqual(cosine(a, b), 1, accuracy: 1e-5) + } + + func testDifferentInputsGiveDifferentVectors() throws { + // The counterpart: a model returning a constant would pass every test + // above and match everybody against everybody. + let embedder = try FaceEmbedder() + let one = try embedder.embed(solidImage(.init(red: 0.9, green: 0.2, blue: 0.2, alpha: 1), side: 112)) + let two = try embedder.embed(solidImage(.init(red: 0.1, green: 0.8, blue: 0.7, alpha: 1), side: 112)) + + XCTAssertLessThan(cosine(one, two), 0.99, "the model is not discriminating at all") + } + + // MARK: helpers + + /// The server's rule, reproduced so the tests can talk in its terms. + private func cosine(_ a: [Float], _ b: [Float]) -> Float { + guard a.count == b.count, !a.isEmpty else { return -1 } + let dot = zip(a, b).reduce(Float(0)) { $0 + $1.0 * $1.1 } + let magA = sqrt(a.reduce(0) { $0 + $1 * $1 }) + let magB = sqrt(b.reduce(0) { $0 + $1 * $1 }) + return magA > 0 && magB > 0 ? dot / (magA * magB) : -1 + } + + private func solidImage(_ color: UIColor, side: Int) -> UIImage { + let size = CGSize(width: side, height: side) + return UIGraphicsImageRenderer(size: size).image { context in + color.setFill() + context.fill(CGRect(origin: .zero, size: size)) + } + } +} + +/// How a face-verified punch is put together. +final class FacePunchTests: XCTestCase { + + private func punch(faceToken: String?) -> QueuedPunch { + QueuedPunch( + id: ULID.generate(), punchedAt: Date(), type: "IN", + latitude: 34.5553, longitude: 69.2075, accuracyMeters: 10, + insideFence: true, faceToken: faceToken + ) + } + + func testAPlainPunchIsGPS() { + XCTAssertEqual(punch(faceToken: nil).method, "GPS") + } + + func testAVerifiedPunchIsRecordedAsFACE() { + // So the attendance record says HOW it was made, not just that it was. + XCTAssertEqual(punch(faceToken: "signed.token").method, "FACE") + } + + func testTheTokenSurvivesBeingQueuedOffline() throws { + // A face check made in a valley must still count as face-verified when + // the punch finally sends — the token is short-lived, so this is also + // the case where the server may refuse it, and that is its decision to + // make, not the phone's. + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = OfflineStore(directory: directory) + + let original = punch(faceToken: "signed.token") + store.save([original], to: "punch-outbox") + + let reloaded = try XCTUnwrap(store.load([QueuedPunch].self, from: "punch-outbox")).first + XCTAssertEqual(reloaded?.faceToken, "signed.token") + XCTAssertEqual(reloaded?.method, "FACE") + } +} diff --git a/ios/WorkTrackTests/GeofenceEvaluatorTests.swift b/ios/WorkTrackTests/GeofenceEvaluatorTests.swift new file mode 100644 index 0000000..3f21955 --- /dev/null +++ b/ios/WorkTrackTests/GeofenceEvaluatorTests.swift @@ -0,0 +1,98 @@ +import XCTest +@testable import WorkTrack + +/// Where the worker is, relative to the site. +/// +/// This exists to warn somebody BEFORE they punch. The server decides what +/// actually counts, so the one thing that must hold is that this agrees with +/// it — the rules mirrored here are the ones in +/// backend/functions/src/services/geo.ts. +final class GeofenceEvaluatorTests: XCTestCase { + + /// The Darulaman Palace, near enough to the demo's site. + private let siteLat = 34.4735 + private let siteLng = 69.1300 + + private func fence( + _ id: String, lat: Double, lng: Double, radius: Double, active: Bool = true + ) -> Geofence { + Geofence(id: id, name: id, latitude: lat, longitude: lng, + radiusMeters: radius, active: active) + } + + func testNoFencesMeansAnywhereIsFine() { + // A small business with no office must still be able to punch. + let e = GeofenceEvaluator.evaluate( + latitude: siteLat, longitude: siteLng, accuracyMeters: 5, fences: [] + ) + XCTAssertFalse(e.fencesConfigured) + XCTAssertFalse(e.insideFence) + XCTAssertNil(e.distanceMeters) + } + + func testInactiveFencesAreIgnored() { + let e = GeofenceEvaluator.evaluate( + latitude: siteLat, longitude: siteLng, accuracyMeters: 5, + fences: [fence("old", lat: siteLat, lng: siteLng, radius: 100, active: false)] + ) + XCTAssertFalse(e.fencesConfigured) + } + + func testStandingAtTheCentreIsInside() { + let e = GeofenceEvaluator.evaluate( + latitude: siteLat, longitude: siteLng, accuracyMeters: 5, + fences: [fence("site", lat: siteLat, lng: siteLng, radius: 100)] + ) + XCTAssertTrue(e.insideFence) + XCTAssertEqual(e.distanceMeters ?? -1, 0, accuracy: 1) + } + + func testWellOutsideIsOutside_andSaysHowFar() { + // ~1 km north. "Outside" alone is not actionable; the distance is. + let e = GeofenceEvaluator.evaluate( + latitude: siteLat + 0.009, longitude: siteLng, accuracyMeters: 5, + fences: [fence("site", lat: siteLat, lng: siteLng, radius: 100)] + ) + XCTAssertFalse(e.insideFence) + XCTAssertEqual(e.distanceMeters ?? 0, 1000, accuracy: 60) + } + + func testAccuracyIsCreditedTowardTheRadius() { + // Mirrors the server: a fix known only to ±80 m, 150 m from a 100 m + // fence, is treated as inside. Refusing it would punish the worker for + // the phone's uncertainty — and the server would have accepted it, + // leaving the app and the record disagreeing. + let justOutside = GeofenceEvaluator.evaluate( + latitude: siteLat + 0.00135, longitude: siteLng, accuracyMeters: 0, + fences: [fence("site", lat: siteLat, lng: siteLng, radius: 100)] + ) + XCTAssertFalse(justOutside.insideFence) + + let sameSpotVagueFix = GeofenceEvaluator.evaluate( + latitude: siteLat + 0.00135, longitude: siteLng, accuracyMeters: 80, + fences: [fence("site", lat: siteLat, lng: siteLng, radius: 100)] + ) + XCTAssertTrue(sameSpotVagueFix.insideFence) + } + + func testInsideAnyFenceCounts_notMerelyTheNearest() { + // A compound and a building inside it are both normally mapped. The + // small one can have the nearer centre while the worker stands inside + // the large one; judging only the nearest would refuse him. + let compound = fence("compound", lat: siteLat, lng: siteLng, radius: 500) + let hut = fence("hut", lat: siteLat + 0.0025, lng: siteLng, radius: 20) + + let e = GeofenceEvaluator.evaluate( + latitude: siteLat + 0.0020, longitude: siteLng, accuracyMeters: 5, + fences: [hut, compound] + ) + XCTAssertTrue(e.insideFence) + XCTAssertEqual(e.nearest?.id, "compound", "should attribute to the fence it is inside") + } + + func testHaversineMatchesAKnownDistance() { + // One degree of latitude is ~111.2 km anywhere on the globe. + let d = GeofenceEvaluator.haversineMeters(34.0, 69.0, 35.0, 69.0) + XCTAssertEqual(d, 111_195, accuracy: 500) + } +} diff --git a/ios/WorkTrackTests/KeychainTests.swift b/ios/WorkTrackTests/KeychainTests.swift new file mode 100644 index 0000000..1d9d0c3 --- /dev/null +++ b/ios/WorkTrackTests/KeychainTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import WorkTrack + +/// The Keychain round trip. +/// +/// This test exists because its absence cost an afternoon: with code signing +/// disabled the app had no entitlements, every SecItemAdd returned -34018, and +/// the only symptom was the login screen appearing on every launch. A failing +/// write that returns Void looks exactly like a working one. +final class KeychainTests: XCTestCase { + + private let key = "test-refresh-token" + + override func tearDown() { + Keychain.remove(key) + super.tearDown() + } + + func testWritingActuallySucceeds() { + // The assertion that would have caught it immediately. + XCTAssertTrue(Keychain.set("value", for: key), "Keychain write was refused") + } + + func testRoundTrips() { + Keychain.set("a-refresh-token", for: key) + XCTAssertEqual(Keychain.get(key), "a-refresh-token") + } + + func testOverwritesRatherThanDuplicating() { + Keychain.set("first", for: key) + Keychain.set("second", for: key) + XCTAssertEqual(Keychain.get(key), "second") + } + + func testRemovingLeavesNothingBehind() { + Keychain.set("value", for: key) + Keychain.remove(key) + XCTAssertNil(Keychain.get(key)) + } + + func testMissingKeyIsNilNotACrash() { + XCTAssertNil(Keychain.get("never-written-\(UUID().uuidString)")) + } +} diff --git a/ios/WorkTrackTests/LeaveAndPayTests.swift b/ios/WorkTrackTests/LeaveAndPayTests.swift new file mode 100644 index 0000000..bd037a4 --- /dev/null +++ b/ios/WorkTrackTests/LeaveAndPayTests.swift @@ -0,0 +1,210 @@ +import XCTest +@testable import WorkTrack + +/// Leave balances and payslips. +/// +/// Both screens show numbers a worker checks against their own expectations, +/// so the arithmetic and the decoding are what matter — a wrong figure here +/// does not crash, it just quietly disagrees with their payslip. +final class LeaveAndPayTests: XCTestCase { + + // MARK: leave + + private func balance( + entitled: Double, carried: Double = 0, used: Double, pending: Double + ) -> LeaveBalance { + LeaveBalance( + id: "b1", leaveTypeId: "lt_annual", periodYear: 2026, + entitledDays: entitled, accruedDays: 0, usedDays: used, + carriedOverDays: carried, pendingDays: pending + ) + } + + func testAvailableDaysCountsPendingAsAlreadySpent() { + // The one that matters: a request awaiting a decision has not reduced + // usedDays yet. Ignoring pendingDays would let somebody book the same + // week twice and find out when the second one is refused. + XCTAssertEqual(balance(entitled: 20, used: 2, pending: 3).availableDays, 15) + } + + func testCarryOverIsAddedNotIgnored() { + XCTAssertEqual(balance(entitled: 20, carried: 5, used: 2, pending: 0).availableDays, 23) + } + + func testAnOverdrawnBalanceGoesNegativeRatherThanClampingToZero() { + // Clamping would hide a real situation — somebody who has taken more + // than they had — from the person it concerns most. + XCTAssertEqual(balance(entitled: 5, used: 6, pending: 2).availableDays, -3) + } + + func testDecodesTheServersLeaveBalance() throws { + let json = """ + {"id":"emp_ahmad_lt_annual_2026","employeeId":"emp_ahmad", + "leaveTypeId":"lt_annual","periodYear":2026,"entitledDays":20, + "accruedDays":0,"usedDays":2,"carriedOverDays":0,"pendingDays":0, + "updatedAt":"2026-09-08T16:57:09.202Z"} + """ + let decoded = try JSONDecoder().decode(LeaveBalance.self, from: Data(json.utf8)) + XCTAssertEqual(decoded.availableDays, 18) + } + + func testOnlyAPendingRequestCanBeWithdrawn() throws { + // Offering "withdraw" on a decided request would send a call the server + // refuses, and imply the decision can be undone from here. + for (status, cancellable) in [ + ("PENDING", true), ("APPROVED", false), ("REJECTED", false), ("CANCELLED", false), + ] { + let json = """ + {"id":"lr_1","leaveTypeId":"lt_annual","startDate":"2026-09-11", + "endDate":"2026-09-13","days":3,"reason":"سفر","status":"\(status)", + "decisionNote":null} + """ + let request = try JSONDecoder().decode(LeaveRequest.self, from: Data(json.utf8)) + XCTAssertEqual(request.isCancellable, cancellable, "for \(status)") + } + } + + func testAnUnknownLeaveStatusDoesNotBreakTheList() throws { + let json = """ + {"id":"lr_1","leaveTypeId":"lt_annual","startDate":"2026-09-11", + "endDate":"2026-09-13","days":3,"reason":"x","status":"ESCALATED", + "decisionNote":null} + """ + let request = try JSONDecoder().decode(LeaveRequest.self, from: Data(json.utf8)) + XCTAssertEqual(request.status, .pending) + } + + // MARK: payslips + + func testPayslipYearIsSolarHijriNotGregorian() { + // routes/payslips.ts validates 1300–1500 and records that a Gregorian + // range "rejected every request the app has ever made". Sending 2026 + // returns an empty list from a healthy server — which reads to a worker + // as never having been paid. + let year = AfghanCalendar.currentShamsiYear( + now: AfghanCalendar.parseISODate("2026-09-08")! + ) + XCTAssertEqual(year, 1405) + XCTAssertTrue((1300...1500).contains(year)) + } + + func testEmployerCostIsNotADeductionFromTheWorker() throws { + // The bug this exists to stop: treating anything-not-EARNING as a + // deduction put the company's pension contribution in the worker's + // column, so the deductions no longer summed to totalDeductions — which + // on a payslip reads as having been underpaid. + let json = """ + {"id":"p1","periodYear":1405,"periodMonth":6,"currency":"AFN", + "gross":34500,"totalDeductions":2633.34,"net":31866.66,"incomeTax":1596.3, + "workedDays":14,"paidLeaveDays":0,"lopDays":1,"status":"FINALIZED", + "lines":[ + {"componentCode":"BASIC","componentName":"معاش اساسی","type":"EARNING","amount":28000}, + {"componentCode":"LOP","componentName":"کسر غیرحاضری","type":"DEDUCTION","amount":1037.04}, + {"componentCode":"TAX","componentName":"مالیهٔ معاش","type":"DEDUCTION","amount":1596.3}, + {"componentCode":"PENSION","componentName":"سهم کارفرما","type":"EMPLOYER_COST","amount":1400}]} + """ + let slip = try JSONDecoder().decode(Payslip.self, from: Data(json.utf8)) + + XCTAssertEqual(slip.employerCosts.map(\.componentCode), ["PENSION"]) + XCTAssertFalse(slip.deductions.contains { $0.componentCode == "PENSION" }) + + // The column has to add up to the server's own total. + let sum = slip.deductions.reduce(0) { $0 + $1.amount } + XCTAssertEqual(sum, slip.totalDeductions, accuracy: 0.001) + } + + func testAnUnknownLineTypeIsNotTreatedAsADeduction() throws { + // Inventing a deduction is the one direction that must never happen by + // accident. + let json = """ + {"id":"p1","periodYear":1405,"periodMonth":6,"currency":"AFN","gross":1, + "totalDeductions":0,"net":1,"incomeTax":0,"workedDays":null, + "paidLeaveDays":null,"lopDays":null,"status":"FINALIZED", + "lines":[{"componentCode":"X","componentName":"جدید","type":"SOMETHING_NEW","amount":5}]} + """ + let slip = try JSONDecoder().decode(Payslip.self, from: Data(json.utf8)) + XCTAssertTrue(slip.deductions.isEmpty) + } + + func testSplitsEarningsFromDeductions() throws { + let json = """ + {"id":"p1","periodYear":1405,"periodMonth":6,"currency":"AFN", + "gross":34500,"totalDeductions":2633.34,"net":31866.66,"incomeTax":1596.3, + "workedDays":14,"paidLeaveDays":0,"lopDays":1,"status":"FINALIZED", + "lines":[ + {"componentCode":"BASIC","componentName":"معاش اساسی","type":"EARNING","amount":28000}, + {"componentCode":"TAX","componentName":"مالیه","type":"DEDUCTION","amount":1596.3}]} + """ + let slip = try JSONDecoder().decode(Payslip.self, from: Data(json.utf8)) + + XCTAssertEqual(slip.earnings.map(\.componentCode), ["BASIC"]) + XCTAssertEqual(slip.deductions.map(\.componentCode), ["TAX"]) + XCTAssertEqual(slip.net, 31866.66) + XCTAssertEqual(slip.lopDays, 1, "unpaid absence must survive decoding — people ask about it") + } + + func testAPayslipWithNoLinesStillDecodes() throws { + // The list endpoint is the same shape whether or not lines are present. + let json = """ + {"id":"p1","periodYear":1405,"periodMonth":6,"currency":"AFN","gross":1, + "totalDeductions":0,"net":1,"incomeTax":0,"workedDays":null, + "paidLeaveDays":null,"lopDays":null,"status":"FINALIZED"} + """ + let slip = try JSONDecoder().decode(Payslip.self, from: Data(json.utf8)) + XCTAssertTrue(slip.earnings.isEmpty) + } + + // MARK: names in the chosen language + + override func tearDown() { + L.language = .dari + super.tearDown() + } + + func testBuiltInLeaveTypesFollowTheLanguage() { + L.language = .english + let annual = LeaveType(id: "annual", name: "رخصتی سالانه", code: "ANNUAL", colorHex: nil, isPaid: true) + let sick = LeaveType(id: "sick", name: "رخصتی مریضی", code: "SICK", colorHex: nil, isPaid: true) + XCTAssertEqual(annual.displayName, "Annual leave") + XCTAssertEqual(sick.displayName, "Sick leave") + } + + func testALeaveTypeTheCompanyNamedKeepsItsName() { + L.language = .english + let renamed = LeaveType(id: "annual", name: "رخصتی تفریحی", code: "ANNUAL", colorHex: nil, isPaid: true) + let custom = LeaveType(id: "hajj", name: "رخصتی حج", code: "HAJJ", colorHex: nil, isPaid: true) + XCTAssertEqual(renamed.displayName, "رخصتی تفریحی") + XCTAssertEqual(custom.displayName, "رخصتی حج") + } + + func testPayrollLinesFollowTheLanguageButCompanyComponentsDoNot() { + L.language = .english + func line(_ code: String, _ name: String) -> PayslipLine { + PayslipLine(componentCode: code, componentName: name, type: .earning, amount: 1) + } + XCTAssertEqual(line("BASIC", "معاش اساسی").displayName, "Basic salary") + XCTAssertEqual(line("LOP", "کسر غیرحاضری").displayName, "Absence deduction") + XCTAssertEqual(line("TAX", "مالیهٔ معاش").displayName, "Income tax") + XCTAssertEqual(line("TRANSPORT", "کمک ترانسپورت").displayName, "کمک ترانسپورت") + } + + // MARK: money + + func testMoneyIsLocalisedAndNamed() { + let text = AfghanCalendar.money(31866.66, currency: "AFN", language: .dari) + XCTAssertTrue(text.contains("افغانی"), text) + XCTAssertFalse(text.contains("3"), "Latin digits in a Dari figure: \(text)") + } + + func testWholeAmountsHaveNoDecimals() { + // "۲۸٬۰۰۰ افغانی", not "۲۸٬۰۰۰٫۰۰". + let text = AfghanCalendar.money(28000, currency: "AFN", language: .english) + XCTAssertEqual(text, "28,000 AFN") + } + + func testEnglishKeepsLatinDigits() { + XCTAssertEqual( + AfghanCalendar.money(1234.5, currency: "AFN", language: .english), "1,234.50 AFN" + ) + } +} diff --git a/ios/WorkTrackTests/MeRefreshTests.swift b/ios/WorkTrackTests/MeRefreshTests.swift new file mode 100644 index 0000000..432bdf7 --- /dev/null +++ b/ios/WorkTrackTests/MeRefreshTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import WorkTrack + +/// Re-reading `me` when the app comes forward. +/// +/// Features ride on `me`, and `me` used to be read only at launch. So a +/// manager could switch face check-in on in the portal, tell the worker to +/// look, and nothing would happen until the app was force-quit — with nothing +/// on screen to say why. +/// +/// Two things have to hold for the fix to work, and neither of them fails +/// loudly if it breaks. +final class MeRefreshTests: XCTestCase { + + private func me(face: Bool?) -> Me { + Me( + employeeId: "e1", companyId: "c1", displayName: "احمد کریمی", + companyName: "شرکت ساختمانی کابل", roles: ["EMPLOYEE"], + faceEnrolled: false, + features: face.map { Me.Features(faceRecognition: $0) } + ) + } + + // MARK: - What a failed refresh does to the session + + func testATemporaryServerFailureKeepsThePersonSignedIn() { + // THE one to get right. This runs every time the app comes forward, + // on a lot of phones, and the wrong answer here is not a crash — it + // is a site full of workers on the login screen the first time the + // server has a bad minute. They cannot sign back in either: that + // needs the same server. + for status in [500, 502, 503] { + XCTAssertEqual( + AuthStore.outcome(for: ApiError.problem(status: status, code: "x", detail: "")), + .keep, + "a \(status) must not sign anybody out" + ) + } + } + + func testNoSignalKeepsThePersonSignedIn() { + // A site with no mast is the normal case here, not the exception. + XCTAssertEqual(AuthStore.outcome(for: ApiError.offline), .keep) + } + + func testAnUnreadableBodyKeepsThePersonSignedIn() { + // A server that shipped a shape this build does not know is a reason + // to carry on with what we have, not to throw the session away. + XCTAssertEqual(AuthStore.outcome(for: ApiError.malformedResponse), .keep) + } + + func testAnUnknownErrorKeepsThePersonSignedIn() { + // Whatever it turns out to be, staying signed in is the safe default. + XCTAssertEqual( + AuthStore.outcome(for: NSError(domain: "somewhere", code: 1)), + .keep + ) + } + + func testARevokedTokenEndsTheSession() { + // The other side of it, and this one is wanted: disabling an employee + // revokes their token, so somebody who has left the company stops + // being in the app at the next foreground rather than lingering until + // they happen to tap something. + XCTAssertEqual(AuthStore.outcome(for: ApiError.unauthenticated), .endSession) + } + + func testAForbiddenIsNotARevokedToken() { + // 403 is "you may not do that", not "you are nobody". Signing out on + // it would eject anyone who hit a permission wall. + XCTAssertEqual( + AuthStore.outcome(for: ApiError.problem(status: 403, code: "FORBIDDEN", detail: "")), + .keep + ) + } + + // MARK: - Noticing that something changed + + func testSwitchingFaceOnMakesADifferentPerson() { + // refreshMe only republishes when the new `me` differs from the old + // one, to avoid re-rendering every screen on every foreground. If + // equality ever stopped accounting for features, the refresh would + // fetch the new flag, compare equal, publish nothing — and the button + // would stay hidden exactly as before. The whole fix rests on this. + XCTAssertNotEqual(me(face: false), me(face: true)) + XCTAssertNotEqual(me(face: nil), me(face: true)) + } + + func testAnUnchangedPersonIsUnchanged() { + XCTAssertEqual(me(face: true), me(face: true)) + } +} diff --git a/ios/WorkTrackTests/PunchOutboxTests.swift b/ios/WorkTrackTests/PunchOutboxTests.swift new file mode 100644 index 0000000..361ab88 --- /dev/null +++ b/ios/WorkTrackTests/PunchOutboxTests.swift @@ -0,0 +1,147 @@ +import XCTest +@testable import WorkTrack + +/// The queue that holds a worker's pay until there is signal. +/// +/// Every one of these is about not losing a punch, or not counting one twice. +@MainActor +final class PunchOutboxTests: XCTestCase { + + private var directory: URL! + private var store: OfflineStore! + + override func setUp() { + super.setUp() + directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + store = OfflineStore(directory: directory) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: directory) + super.tearDown() + } + + private func punch( + _ id: String = ULID.generate(), at date: Date = Date(), type: String = "IN" + ) -> QueuedPunch { + QueuedPunch( + id: id, punchedAt: date, type: type, + latitude: 34.5553, longitude: 69.2075, accuracyMeters: 10, insideFence: true + ) + } + + func testSurvivesTheAppBeingKilled() { + // The whole point. A phone that dies in a valley must still be holding + // the punch when it comes back on. + let first = PunchOutbox(store: store) + first.enqueue(punch("01ABCDEFGHJKMNPQRSTVWXYZ00")) + XCTAssertEqual(first.pending.count, 1) + + let reopened = PunchOutbox(store: store) + XCTAssertEqual(reopened.pending.count, 1) + XCTAssertEqual(reopened.pending.first?.id, "01ABCDEFGHJKMNPQRSTVWXYZ00") + } + + func testTheSamePunchIsNeverQueuedTwice() { + let outbox = PunchOutbox(store: store) + let p = punch("01ABCDEFGHJKMNPQRSTVWXYZ01") + outbox.enqueue(p) + outbox.enqueue(p) + XCTAssertEqual(outbox.pending.count, 1) + } + + func testKeepsTheTimeItHappened_notTheTimeItIsSent() throws { + // A man who checked in at 07:00 with no signal was at work at 07:00. + let sevenAM = Date(timeIntervalSince1970: 1_800_000_000) + let outbox = PunchOutbox(store: store) + outbox.enqueue(punch(at: sevenAM)) + + let reopened = PunchOutbox(store: store) + let stored = try XCTUnwrap(reopened.pending.first).punchedAt + XCTAssertEqual(stored.timeIntervalSince1970, sevenAM.timeIntervalSince1970, accuracy: 0.001) + } + + func testDropsPunchesTheServerWillNeverAccept() { + // Server rule: older than 7 days is refused as TOO_OLD. Retrying it + // forever would keep a queue that can never drain. + let outbox = PunchOutbox(store: store) + let old = punch("01ABCDEFGHJKMNPQRSTVWXYZ02", at: Date().addingTimeInterval(-8 * 86_400)) + let fresh = punch("01ABCDEFGHJKMNPQRSTVWXYZ03") + outbox.enqueue(old) + outbox.enqueue(fresh) + + let discarded = outbox.discardExpired() + XCTAssertEqual(discarded.map(\.id), [old.id]) + XCTAssertEqual(outbox.pending.map(\.id), [fresh.id]) + } + + func testKeepsAPunchThatIsStillWithinTheWindow() { + let outbox = PunchOutbox(store: store) + outbox.enqueue(punch(at: Date().addingTimeInterval(-6 * 86_400))) + XCTAssertTrue(outbox.discardExpired().isEmpty) + XCTAssertEqual(outbox.pending.count, 1) + } + + func testRemovingOneLeavesTheRest() { + let outbox = PunchOutbox(store: store) + outbox.enqueue(punch("01ABCDEFGHJKMNPQRSTVWXYZ04")) + outbox.enqueue(punch("01ABCDEFGHJKMNPQRSTVWXYZ05")) + outbox.remove(id: "01ABCDEFGHJKMNPQRSTVWXYZ04") + + XCTAssertEqual(PunchOutbox(store: store).pending.map(\.id), + ["01ABCDEFGHJKMNPQRSTVWXYZ05"]) + } +} + +/// The cached day. +@MainActor +final class WorkCacheTests: XCTestCase { + + private var directory: URL! + + override func setUp() { + super.setUp() + directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: directory) + super.tearDown() + } + + func testHandsBackTheLastPlanAfterARestart() { + // So a worker on a site with no signal opens the app to his job, not + // to a spinner. + let store = OfflineStore(directory: directory) + let task = WorkTask( + id: "t1", projectName: "برج دارالامان", title: "قالب‌بندی", + detail: nil, location: "بلاک B", status: .inProgress, + teamName: "تیم کانکریت", assigneeNames: ["احمد", "عمر"] + ) + let work = MyWork( + today: WorkDay(date: "2026-09-08", kind: .working, tasks: [task]), + next: WorkDay(date: "2026-09-09", kind: .working, tasks: []) + ) + WorkCache(store: store).save(work: work, attendance: nil, fences: []) + + let reopened = WorkCache(store: store).load() + XCTAssertEqual(reopened?.work?.today.tasks.first?.title, "قالب‌بندی") + XCTAssertEqual(reopened?.work?.today.tasks.first?.status, .inProgress) + XCTAssertNotNil(reopened?.fetchedAt) + } + + func testAnEmptyCacheIsNotAnError() { + XCTAssertNil(WorkCache(store: OfflineStore(directory: directory)).load()) + } + + func testKeepsTheFencesSoADistanceCanStillBeShownOffline() { + let store = OfflineStore(directory: directory) + let fence = Geofence(id: "f1", name: "دفتر", latitude: 34.5553, + longitude: 69.2075, radiusMeters: 250, active: true) + WorkCache(store: store).save(work: nil, attendance: nil, fences: [fence]) + + XCTAssertEqual(WorkCache(store: store).load()?.fences.first?.radiusMeters, 250) + } +} diff --git a/ios/WorkTrackTests/RegularizationTests.swift b/ios/WorkTrackTests/RegularizationTests.swift new file mode 100644 index 0000000..3291676 --- /dev/null +++ b/ios/WorkTrackTests/RegularizationTests.swift @@ -0,0 +1,201 @@ +import XCTest +@testable import WorkTrack + +/// Asking for a day to be corrected. +@MainActor +final class RegularizationTests: XCTestCase { + + private func correction(date: String, status: String) throws -> Regularization { + let json = """ + {"id":"reg_1","date":"\(date)","requestedInAt":"2026-09-05T08:00:00.000Z", + "requestedOutAt":null,"reason":"فراموش کردم خروج بزنم","status":"\(status)", + "decisionNote":null} + """ + return try JSONDecoder().decode(Regularization.self, from: Data(json.utf8)) + } + + func testOnlyOneTimeNeedsCorrecting() throws { + // Somebody who forgot to check OUT should not have to restate when he + // arrived; a restated time that differs slightly reads as a second + // thing to approve. + let request = try correction(date: "2026-09-05", status: "PENDING") + XCTAssertNotNil(request.requestedInAt) + XCTAssertNil(request.requestedOutAt) + } + + func testFindsThePendingCorrectionForADay() throws { + let overview = AttendanceHistoryViewModel.Overview( + days: [], + corrections: [try correction(date: "2026-09-05", status: "PENDING")] + ) + XCTAssertNotNil(overview.pendingCorrection(on: "2026-09-05")) + XCTAssertNil(overview.pendingCorrection(on: "2026-09-06")) + } + + func testADecidedCorrectionDoesNotBlockAskingAgain() throws { + // Rejected is not pending: if the manager turned it down, the worker + // may put it right and ask again. Treating any correction as blocking + // would leave him with no route at all. + for status in ["APPROVED", "REJECTED", "CANCELLED"] { + let overview = AttendanceHistoryViewModel.Overview( + days: [], corrections: [try correction(date: "2026-09-05", status: status)] + ) + XCTAssertNil(overview.pendingCorrection(on: "2026-09-05"), "for \(status)") + } + } + + func testAnUnknownStatusDoesNotBreakTheList() throws { + XCTAssertEqual(try correction(date: "2026-09-05", status: "ESCALATED").status, .pending) + } + + func testTheDayIsResolvedInTheCompanysTimezone() { + // A worker checking his history from another country must see the same + // day his site is living, not his handset's. + let noon = Date(timeIntervalSince1970: 1_788_000_000) + let kabul = AttendanceHistoryViewModel.isoDate(noon) + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Kabul")! + let parts = calendar.dateComponents([.year, .month, .day], from: noon) + XCTAssertEqual( + kabul, + String(format: "%04d-%02d-%02d", parts.year!, parts.month!, parts.day!) + ) + } + + func testEveryStatusTheProductUsesIsHandled() throws { + // The exact set from AttendanceDayStatus in core/model/Attendance.kt. + // Guessing these by hand is how "WEEK_OFF" ended up rendering raw on + // screen beside properly translated neighbours. + let expected: [String: AttendanceDayStatus] = [ + "PRESENT": .present, "ABSENT": .absent, "HALF_DAY": .halfDay, + "LEAVE": .leave, "HOLIDAY": .holiday, "WEEK_OFF": .weekOff, + "PENDING": .pending, + ] + for (raw, status) in expected { + let json = """ + {"date":"2026-09-08","status":"\(raw)","firstInAt":null, + "lastOutAt":null,"workedMinutes":0} + """ + let day = try JSONDecoder().decode(AttendanceDay.self, from: Data(json.utf8)) + XCTAssertEqual(day.status, status, "for \(raw)") + XCTAssertFalse(day.status!.label.isEmpty) + // The label must be translated, never the wire value shown raw. + XCTAssertNotEqual(day.status!.label, raw, "\(raw) is showing untranslated") + } + } + + func testAnUnknownDayStatusFallsBackWithoutShowingTheRawValue() throws { + let json = """ + {"date":"2026-09-08","status":"SOMETHING_NEW","firstInAt":null, + "lastOutAt":null,"workedMinutes":0} + """ + let day = try JSONDecoder().decode(AttendanceDay.self, from: Data(json.utf8)) + XCTAssertEqual(day.status, .pending) + XCTAssertNotEqual(day.status!.label, "SOMETHING_NEW") + } + + func testAttendanceDayKnowsWhenSomebodyIsStillIn() throws { + // The punch card reads this to decide whether the button says check in + // or check out. + let stillIn = """ + {"date":"2026-09-08","status":"PRESENT","firstInAt":"2026-09-08T03:30:00Z", + "lastOutAt":null,"workedMinutes":120} + """ + let doneForTheDay = """ + {"date":"2026-09-08","status":"PRESENT","firstInAt":"2026-09-08T03:30:00Z", + "lastOutAt":"2026-09-08T12:00:00Z","workedMinutes":480} + """ + XCTAssertTrue(try JSONDecoder().decode(AttendanceDay.self, from: Data(stillIn.utf8)).isClockedIn) + XCTAssertFalse(try JSONDecoder().decode(AttendanceDay.self, from: Data(doneForTheDay.utf8)).isClockedIn) + } +} + +/// The three languages, held in step. +final class LocalizationParityTests: XCTestCase { + + func testEveryLanguageDefinesTheSameKeys() { + // Three dictionaries edited by hand: a string added to Dari and + // forgotten in Pashto renders the raw key on screen to exactly the + // users least likely to report it. + let dari = L.keys(for: .dari) + for language in [Language.pashto, .english] { + let keys = L.keys(for: language) + XCTAssertEqual( + keys.symmetricDifference(dari), [], + "\(language.rawValue) is out of step with Dari" + ) + } + } + + func testNoKeyIsLeftUntranslated() { + // t() falls back to the key itself, so an untranslated string looks + // like "common_cancel" in the middle of a Dari screen. + for language in Language.allCases { + L.language = language + for key in L.keys(for: language) { + XCTAssertNotEqual(L.t(key), key, "\(key) is not translated in \(language.rawValue)") + } + } + L.language = .dari + } +} + +/// The two timezones that meet when somebody corrects a time. +final class CorrectionTimeTests: XCTestCase { + + private func utcString(_ date: Date) -> String { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + f.timeZone = TimeZone(identifier: "UTC") + return f.string(from: date) + } + + /// A Date whose wall clock reads `hour:minute` in `zone`. + private func wallClock(_ hour: Int, _ minute: Int, in zone: String) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: zone)! + return calendar.date( + from: DateComponents( + timeZone: TimeZone(identifier: zone), year: 2026, month: 9, day: 7, + hour: hour, minute: minute + ) + )! + } + + func testTheTimeMeansWhatItSaidAtTheSite() { + // A worker in Kabul picks 14:02 and means 14:02 at the site. + // 14:02 Kabul is 09:32 UTC. + let picked = wallClock(14, 2, in: "Asia/Kabul") + let result = CorrectionRequestView.combine( + "2026-09-07", picked, deviceZone: TimeZone(identifier: "Asia/Kabul")! + ) + XCTAssertEqual(utcString(result!), "2026-09-07T09:32:00Z") + } + + func testAPhoneSetToAnotherCountryStillMeansTheSitesClock() { + // The bug this exists to stop. The picker showed 14:02 to somebody + // whose handset is on Toronto time; reading those components in Kabul + // instead turned the request into 22:32, and nothing said so. + let pickedOnAToronto = wallClock(14, 2, in: "America/Toronto") + let result = CorrectionRequestView.combine( + "2026-09-07", pickedOnAToronto, deviceZone: TimeZone(identifier: "America/Toronto")! + ) + // Still 14:02 at the site — the digits the person saw and chose. + XCTAssertEqual(utcString(result!), "2026-09-07T09:32:00Z") + } + + func testTheDateIsTheDayBeingCorrected_notToday() { + // The picker only offers a time; the day comes from the row that was + // tapped, or every correction would land on today. + let picked = wallClock(8, 0, in: "Asia/Kabul") + let result = CorrectionRequestView.combine( + "2026-09-01", picked, deviceZone: TimeZone(identifier: "Asia/Kabul")! + ) + XCTAssertTrue(utcString(result!).hasPrefix("2026-09-01"), utcString(result!)) + } + + func testAnUnparseableDateIsRefusedRatherThanGuessed() { + XCTAssertNil(CorrectionRequestView.combine("not-a-date", Date())) + } +} diff --git a/ios/WorkTrackTests/ULIDTests.swift b/ios/WorkTrackTests/ULIDTests.swift new file mode 100644 index 0000000..31d810d --- /dev/null +++ b/ios/WorkTrackTests/ULIDTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import WorkTrack + +/// The punch id, which is what makes a punch idempotent. +final class ULIDTests: XCTestCase { + + func testIsExactlyWhatTheServerAccepts() { + // punchCreateSchema requires length 26; a 25 or 27 would be rejected + // for every punch, and only in the field. + for _ in 0..<200 { + XCTAssertEqual(ULID.generate().count, 26) + } + } + + func testUsesCrockfordBase32Only() { + let allowed = Set("0123456789ABCDEFGHJKMNPQRSTVWXYZ") + for _ in 0..<200 { + XCTAssertTrue(ULID.generate().allSatisfy { allowed.contains($0) }) + } + } + + func testIsUnique() { + // Two punches in the same millisecond must not collide into one + // document — the id is the idempotency key. + let now = Date() + let ids = Set((0..<2000).map { _ in ULID.generate(at: now) }) + XCTAssertEqual(ids.count, 2000) + } + + func testSortsByTime() { + let earlier = ULID.generate(at: Date(timeIntervalSince1970: 1_000_000)) + let later = ULID.generate(at: Date(timeIntervalSince1970: 2_000_000)) + XCTAssertLessThan(earlier.prefix(10), later.prefix(10)) + } +} diff --git a/ios/WorkTrackTests/WorkModelsTests.swift b/ios/WorkTrackTests/WorkModelsTests.swift new file mode 100644 index 0000000..c998c3b --- /dev/null +++ b/ios/WorkTrackTests/WorkModelsTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import WorkTrack + +/// Decoding what the server actually sends. +final class WorkModelsTests: XCTestCase { + + private func decode(_ json: String) throws -> MyWork { + try JSONDecoder().decode(MyWork.self, from: Data(json.utf8)) + } + + func testDecodesADayOfWork() throws { + let work = try decode(""" + {"today":{"date":"2026-09-08","kind":"WORKING","tasks":[ + {"id":"t1","projectName":"برج دارالامان","title":"قالب‌بندی","detail":null, + "location":"بلاک B","status":"IN_PROGRESS","teamName":"تیم کانکریت", + "assigneeNames":["احمد کریمی","عمر صدیقی"]}]}, + "next":{"date":"2026-09-09","kind":"WORKING","tasks":[]}} + """) + + XCTAssertEqual(work.today.tasks.count, 1) + XCTAssertEqual(work.today.tasks[0].status, .inProgress) + XCTAssertTrue(work.today.tasks[0].isTeamWork) + XCTAssertEqual(work.next?.date, "2026-09-09") + XCTAssertTrue(work.next?.tasks.isEmpty ?? false) + } + + func testOneNameIsNotTeamWork() throws { + let work = try decode(""" + {"today":{"date":"2026-09-08","kind":"WORKING","tasks":[ + {"id":"t1","projectName":"p","title":"t","detail":null,"location":null, + "status":"PLANNED","teamName":null,"assigneeNames":["Ali"]}]},"next":null} + """) + XCTAssertFalse(work.today.tasks[0].isTeamWork) + XCTAssertNil(work.next) + } + + func testAnUnknownStatusDoesNotBlankTheDay() throws { + // A server that grows a fifth status must not cost an employee their + // whole day's plan. + let work = try decode(""" + {"today":{"date":"2026-09-08","kind":"SOMETHING_NEW","tasks":[ + {"id":"t1","projectName":"p","title":"t","detail":null,"location":null, + "status":"ON_HOLD","teamName":null,"assigneeNames":[]}]},"next":null} + """) + XCTAssertEqual(work.today.tasks[0].status, .planned) + XCTAssertEqual(work.today.kind, .working) + } + + func testWeekendIsCarriedSoTheAppCanSayWhyADayIsEmpty() throws { + let work = try decode(""" + {"today":{"date":"2026-09-11","kind":"WEEKEND","tasks":[]},"next":null} + """) + XCTAssertEqual(work.today.kind, .weekend) + } +} diff --git a/ios/check-strings.py b/ios/check-strings.py new file mode 100755 index 0000000..dac2049 --- /dev/null +++ b/ios/check-strings.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Every L.t("…") in the views must exist in all three dictionaries. + +A missing key is not an error at runtime — L.t returns the key itself, so the +screen shows "common_cancel" where a button label should be. That is invisible +to anyone testing in English-only, and invisible in a unit test that never +renders the view. So it is checked here instead. + + python3 ios/check-strings.py +""" +import re, sys, pathlib + +root = pathlib.Path(__file__).parent +used = set() +for f in (root / "WorkTrack").rglob("*.swift"): + used |= set(re.findall(r'L\.t\("([^"]+)"\)', f.read_text())) + +loc = (root / "WorkTrack/Core/Localization.swift").read_text() +dicts = {} +for lang in (".dari", ".pashto", ".english"): + start = loc.index(f"{lang}: [") + end = loc.index("]", loc.index('"retry"', start)) + dicts[lang] = set(re.findall(r'"([a-z_0-9]+)":', loc[start:end])) + +failed = False +for lang, keys in dicts.items(): + missing = sorted(used - keys) + if missing: + failed = True + print(f"{lang}: {len(missing)} key(s) used but not defined: {missing}") + +shared = set.intersection(*dicts.values()) +for lang, keys in dicts.items(): + only = sorted(keys - shared) + if only: + failed = True + print(f"{lang}: defines keys the other languages do not: {only}") + +print(f"{len(used)} keys used across the views" + ("" if failed else " — all present in all three languages")) +sys.exit(1 if failed else 0) diff --git a/ios/exportOptions.plist b/ios/exportOptions.plist new file mode 100644 index 0000000..956497a --- /dev/null +++ b/ios/exportOptions.plist @@ -0,0 +1,10 @@ + + + + + methodapp-store-connect + teamID27RXPRW77S + uploadSymbols + signingStyleautomatic + + diff --git a/ios/make-appicon.py b/ios/make-appicon.py new file mode 100644 index 0000000..341b60d --- /dev/null +++ b/ios/make-appicon.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Renders the iOS app icon from the same mark the Android launcher uses. + +The two apps are one product and should be recognisable as such on a desk with +both phones on it, so this reproduces ic_launcher_foreground.xml exactly — the +clock ring and its two hands, in the 108-unit viewport that file is drawn in — +on the same #006874 ground from colors.xml. + +A clock, not a tool: the app is about hours and records, and the tab bar and +icon should say office rather than site. + + python3 ios/make-appicon.py +""" +from PIL import Image, ImageDraw +import pathlib + +TEAL = (0, 104, 116) # #006874, ic_launcher_background +UNIT = 108 # the Android vector's viewport +SIZE = 1024 # what App Store Connect and iOS want +SS = 4 # supersample, then downscale for clean edges + +canvas = SIZE * SS +scale = canvas / UNIT +image = Image.new("RGB", (canvas, canvas), TEAL) +draw = ImageDraw.Draw(image) + +def box(cx, cy, r): + return [((cx - r) * scale, (cy - r) * scale), ((cx + r) * scale, (cy + r) * scale)] + +# Ring: outer circle minus inner, exactly as the even-odd path does. +draw.ellipse(box(54, 54, 24), fill="white") +draw.ellipse(box(54, 54, 18), fill=TEAL) + +# Hands: 12-to-centre, and centre-to-4-o'clock. +draw.rectangle([(52 * scale, 42 * scale), (56 * scale, 55 * scale)], fill="white") +draw.polygon( + [(53 * scale, 53 * scale), (62.5 * scale, 58.5 * scale), + (60.5 * scale, 61.9 * scale), (51 * scale, 56.4 * scale)], + fill="white", +) + +out = pathlib.Path(__file__).parent / "WorkTrack/Assets.xcassets/AppIcon.appiconset" +out.mkdir(parents=True, exist_ok=True) +image.resize((SIZE, SIZE), Image.LANCZOS).save(out / "icon-1024.png") +print(f"wrote {out/'icon-1024.png'}") diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000..a757f35 --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,132 @@ +# XcodeGen spec. The .xcodeproj is GENERATED from this file and is not in git — +# a pbxproj is a merge-conflict machine that nobody can review, and this is the +# same file in a form a person can read. +# +# cd ios && xcodegen generate && open WorkTrack.xcodeproj +# +name: WorkTrack +options: + bundleIdPrefix: app.worktrack + deploymentTarget: + iOS: "16.0" # iPhone 8 and later — secondhand phones matter here + createIntermediateGroups: true + groupSortPosition: top + +settings: + base: + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "3" + SWIFT_VERSION: "5.9" + # Simulator builds are signed ad-hoc ("-"). That is not cosmetic: the + # Keychain refuses an app with no entitlements and SecItemAdd fails with + # -34018, which showed up as the app asking for the password on every + # launch, silently, because nothing checked the status. Keep the simulator + # on manual ad-hoc signing; it needs no account and no network. + CODE_SIGN_IDENTITY[sdk=iphonesimulator*]: "-" + CODE_SIGN_STYLE[sdk=iphonesimulator*]: Manual + CODE_SIGNING_REQUIRED: "YES" + CODE_SIGNING_ALLOWED: "YES" + + # Building for a real iPhone, and TestFlight, need the Apple Developer + # team — this is the WorkTrack membership's Team ID. + DEVELOPMENT_TEAM: "27RXPRW77S" + CODE_SIGN_IDENTITY[sdk=iphoneos*]: "Apple Development" + CODE_SIGN_STYLE[sdk=iphoneos*]: Automatic + # + # Automatic signing gets the certificate and profile from Apple, so the + # machine has to be signed in first: Xcode -> Settings -> Accounts -> "+". + # Without it the build stops with "No Accounts: Add a new account in + # Accounts settings", which is about THIS Mac, not about the Team ID being + # wrong. Then: + # xcodebuild -workspace WorkTrack.xcworkspace -scheme WorkTrack \ + # -destination 'generic/platform=iOS' -allowProvisioningUpdates + # + # A Team ID is an identifier, not a secret — it is printed in every + # provisioning profile and every App Store URL — so unlike the Android + # keystore it belongs in the repository. What must never be committed is + # the signing certificate's private key and the App Store Connect API key. + +targets: + WorkTrack: + type: application + platform: iOS + sources: + - path: WorkTrack + # The model is a bundle RESOURCE, not source. Same file as the Android + # app's asset — see Face/FaceEmbedder.swift for why that matters. + - path: WorkTrack/Face/mobilefacenet.tflite + buildPhase: resources + info: + path: WorkTrack/Info.plist + properties: + CFBundleDisplayName: WorkTrack + # WorkTrack/Info.plist is GENERATED from this block on every `xcodegen + # generate`, so editing the plist to bump a version is silently undone + # — it looks applied, and the next generate restores XcodeGen's own + # defaults of 1.0 and 1. Declare it here, once, and let the plist point + # at the build settings above. + # + # CFBundleVersion must go UP on every upload: Apple refuses a build + # number App Store Connect has already seen, and the refusal comes + # after the whole archive-export-upload cycle has run. + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + UILaunchScreen: {} + # TestFlight and the App Store ask, on every single build, whether the + # app uses encryption beyond what Apple exempts. This app talks HTTPS + # to its own backend and does nothing else cryptographic — no bundled + # cipher, no key exchange of its own — which is exactly the exempt + # case. Answering here rather than in a web form each time keeps the + # answer honest and version-controlled instead of retyped from memory + # by whoever happens to be uploading. + ITSAppUsesNonExemptEncryption: false + # Dari first, exactly like the portal and the Android app. Without this + # the app inherits the phone's language and an Afghan user with an + # English phone gets an English, left-to-right layout. + CFBundleDevelopmentRegion: fa + CFBundleLocalizations: [fa, ps, en] + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + ITSAppUsesNonExemptEncryption: false + # Asked for at the moment somebody punches, and only then. There is no + # background tracking in this app and no plan for any, so "when in use" + # is the whole claim. + NSCameraUsageDescription: "برای تایید حاضری با چهره، دوربین فقط در همان لحظه استفاده می‌شود. عکس شما جایی ذخیره یا ارسال نمی‌شود." + NSLocationWhenInUseUsageDescription: "برای ثبت حاضری، موقعیت شما یک بار در همان لحظه خوانده می‌شود تا معلوم شود در ساحهٔ کاری هستید." + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: app.worktrack + GENERATE_INFOPLIST_FILE: NO + # The clock mark from the Android launcher, same colour — one product, + # recognisable on a desk with both phones on it. See make-appicon.py. + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + + # iPhone only — a statement of what was actually built and checked, not + # a limitation. Every screen here is a worker's screen, laid out and + # verified at phone width; a manager who wants a big screen opens the + # portal, which is why it was opened to employees in a browser at all. + # + # It belongs at TARGET level: XcodeGen writes its own + # TARGETED_DEVICE_FAMILY = 1,2 per target for an iOS platform, and that + # outranks anything set in the project's base settings, silently. + # + # Claiming iPad costs more than a stretched layout — Apple refuses a + # portrait-only iPad app outright ("you need to include all four + # orientations to support iPad multitasking", error 90474, which is how + # this surfaced). Satisfying it would mean shipping landscape layouts + # nobody has ever looked at. + TARGETED_DEVICE_FAMILY: "1" + + WorkTrackTests: + type: bundle.unit-test + platform: iOS + sources: + - path: WorkTrackTests + settings: + base: + # The test bundle is signed too now, so it needs its own Info.plist; + # let Xcode generate one rather than hand-keeping a second file. + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: app.worktrack.tests + dependencies: + - target: WorkTrack diff --git a/keystore.properties.example b/keystore.properties.example new file mode 100644 index 0000000..8219d82 --- /dev/null +++ b/keystore.properties.example @@ -0,0 +1,14 @@ +# Copy to keystore.properties and fill in. That file is gitignored and must stay +# that way — it holds the password to the release signing key. +# +# Create the key once, outside the repo if you prefer: +# keytool -genkeypair -v -keystore worktrack-release.jks \ +# -alias worktrack -keyalg RSA -keysize 4096 -validity 10000 +# +# Then back the .jks up somewhere durable. If it is lost, this app can never be +# updated again — a new key means a new listing and every user reinstalling. + +storeFile=worktrack-release.jks +storePassword= +keyAlias=worktrack +keyPassword= diff --git a/run-demo.sh b/run-demo.sh new file mode 100755 index 0000000..fba5230 --- /dev/null +++ b/run-demo.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# +# One command to run the whole WorkTrack demo locally. +# +# bash run-demo.sh +# +# It builds the backend, starts the Firebase emulators, seeds the sample Afghan +# tenant, and launches the web manager portal — in the right order, in ONE +# terminal. Press Ctrl+C once to stop everything. +# +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" + +echo "" +echo "==> WorkTrack demo starting. This uses local emulators only (no real Firebase)." +echo "" + +# --- 0. Prerequisites ------------------------------------------------------- +if ! command -v firebase >/dev/null 2>&1; then + echo "✗ Firebase CLI not found. Install it once with:" + echo " npm install -g firebase-tools" + exit 1 +fi +if ! command -v java >/dev/null 2>&1; then + echo "✗ Java not found (the Firestore emulator needs it)." + echo " Install Temurin JDK 21 from https://adoptium.net and re-run." + exit 1 +fi + +# --- 1. Backend ------------------------------------------------------------- +echo "==> Preparing backend…" +cd "$ROOT/backend/functions" +[ -d node_modules ] || { echo " installing backend deps (first run)…"; npm install --silent; } +[ -f .secret.local ] || echo 'KIOSK_HMAC_SECRET=demo-secret' > .secret.local +echo " building functions…" +npm run build --silent + +# --- 2. Web ----------------------------------------------------------------- +echo "==> Preparing web portal…" +cd "$ROOT/web" +[ -d node_modules ] || { echo " installing web deps (first run)…"; npm install --silent; } +[ -f .env.local ] || cp .env.emulator .env.local + +# --- 3. Emulators -> seed -> web (single lifecycle) ------------------------- +# emulators:exec starts the emulators, runs the inner command while they're up, +# and shuts them down when it exits. The inner command seeds the data and then +# runs the web dev server (which blocks until you press Ctrl+C). +echo "==> Starting emulators, seeding data, and launching the portal…" +echo " (first start takes ~20s; the portal URL will be printed below)" +echo "" +cd "$ROOT" +firebase emulators:exec \ + --project demo-worktrack \ + --only functions,firestore,auth \ + "node \"$ROOT/backend/functions/seed.js\" && echo '' && echo '======================================================' && echo ' Portal starting — open the http://localhost URL below' && echo ' Login: admin@worktrack.af Password: Passw0rd!' && echo '======================================================' && echo '' && cd \"$ROOT/web\" && npm run dev" diff --git a/scripts/stage-downloads.sh b/scripts/stage-downloads.sh new file mode 100755 index 0000000..8b82fc0 --- /dev/null +++ b/scripts/stage-downloads.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# +# Stages the signed release APKs into the built portal so Firebase Hosting +# serves them from a stable address customers can be sent to. +# +# The APKs are 130+ MB and are deliberately NOT in git (see .gitignore). They +# are copied into web/dist after `npm run build` and before `firebase deploy`, +# which is why this is a step rather than a checked-in directory: the repo +# stays small and the download always matches the build you just made. +# +# Firebase serves a file that exists in preference to a rewrite, so these win +# over the SPA catch-all in firebase.json without any config change. +# +# Usage, from the repo root: +# +# npm --prefix web run build # with .env.local moved aside +# scripts/stage-downloads.sh +# npx firebase deploy --only hosting --project worktrack-prod +# +# Result: https://worktrack-prod.web.app/app/ +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TARGET="${1:-prod}" + +# Gradle owns these directories and clears stale outputs from them, so read the +# APKs where the build actually leaves them rather than from a hand-kept copy. +case "$TARGET" in + prod) + SRC="$ROOT/app/build/outputs/apk/release" + WEB="$ROOT/web/dist" + DEST="$WEB/app" + GRADLE_TASK=":app:assembleRelease" + WEB_TASK="npm --prefix web run build" + ;; + demo) + # The demo hosting is rebuilt from scratch on every portal deploy, so the + # APKs have to be re-staged each time or the download links on + # linumic.com/…/demo/ start returning the SPA's index.html. + SRC="$ROOT/app/build/outputs/apk/demo" + WEB="$ROOT/web/dist-demo" + DEST="$WEB" + GRADLE_TASK=":app:assembleDemo" + WEB_TASK="npm --prefix web run build -- --mode demo --outDir dist-demo" + ;; + *) + echo "Usage: $0 [prod|demo]" >&2 + exit 1 + ;; +esac + +if [ ! -d "$SRC" ]; then + echo "No $SRC — build the signed APKs first:" >&2 + echo " ./gradlew $GRADLE_TASK" >&2 + exit 1 +fi + +if [ ! -d "$WEB" ]; then + echo "No $WEB — build the portal first:" >&2 + echo " $WEB_TASK" >&2 + exit 1 +fi + +# Version straight from the build output, so the filename cannot drift from the +# thing it names. +VERSION="$( + python3 - "$SRC/output-metadata.json" <<'PY' +import json, sys +m = json.load(open(sys.argv[1])) +print(m["elements"][0]["versionName"]) +PY +)" + +echo "Staging WorkTrack $VERSION ($TARGET)" +if [ "$TARGET" = "prod" ]; then + rm -rf "$DEST" +fi +mkdir -p "$DEST" + +# x86_64 is emulator-only; shipping it to anyone just adds 33 MB of confusion. +# +# The demo filenames are fixed rather than versioned: linumic.com's demo page +# links to them by name, so a version in the filename would break those links +# on every release. +if [ "$TARGET" = "prod" ]; then + declare -a NAMES=( + "app-arm64-v8a-release.apk:worktrack-$VERSION-arm64.apk" + "app-armeabi-v7a-release.apk:worktrack-$VERSION-arm32.apk" + "app-universal-release.apk:worktrack-$VERSION-universal.apk" + ) +else + declare -a NAMES=( + "app-arm64-v8a-demo.apk:worktrack-demo.apk" + "app-armeabi-v7a-demo.apk:worktrack-demo-older-phones.apk" + ) +fi + +for pair in "${NAMES[@]}"; do + from="${pair%%:*}" + to="${pair##*:}" + if [ ! -f "$SRC/$from" ]; then + echo " missing $from — did $GRADLE_TASK run?" >&2 + exit 1 + fi + cp "$SRC/$from" "$DEST/$to" + echo " $to ($(du -h "$DEST/$to" | cut -f1))" +done + +if [ "$TARGET" = "demo" ]; then + echo + echo "Staged into web/dist-demo — deploy the demo hosting to publish:" + echo " npx firebase deploy --only hosting --project worktrack-demo-af --config firebase.demo.json" + exit 0 +fi + +# Customers are told to check this before installing, so it has to be generated +# from the files actually being published, not typed by hand. +( cd "$DEST" && shasum -a 256 ./*.apk | sed 's|\./||' > SHA256SUMS.txt ) +echo " SHA256SUMS.txt" + +cat > "$DEST/index.html" < + + + +دانلود اپلیکیشن ورک‌ترک + +
+

اپلیکیشن ورک‌ترک برای اندروید

+

نسخهٔ $VERSION — برای اندروید ۸ و بالاتر

+ + + گوشی‌های معمول (arm64)
+ تقریباً همهٔ گوشی‌های چند سال اخیر. این را بگیرید. +
+ + گوشی‌های قدیمی‌تر (arm32)
+ اگر نسخهٔ بالا نصب نشد، این را امتحان کنید. +
+ + نسخهٔ همگانی
+ روی همهٔ گوشی‌ها کار می‌کند اما حجمش بیشتر است. +
+ +
+ پیش از نصب، اصالت فایل را بررسی کنید +

فهرست کدهای کنترلی: SHA256SUMS.txt

+

+ امضای رسمی ورک‌ترک (SHA-256 گواهی):
+ e37a2ec8cdd024198dda6db7e97bb30ce03344a9bfbe47ab7db15280f4a7d983 +

+
+ +
+ پشتیبانی +

+ لینومیک — کابل، افغانستان
+ +93 793 817 977 · + contact@linumic.com +

+
+
+ +HTML +echo " index.html" + +echo +echo "Staged into web/dist/app — deploy hosting to publish:" +echo " npx firebase deploy --only hosting --project worktrack-prod" diff --git a/scripts/update-brochure.js b/scripts/update-brochure.js new file mode 100644 index 0000000..197ebf9 --- /dev/null +++ b/scripts/update-brochure.js @@ -0,0 +1,181 @@ +/* + * Updates the WorkTrack brochure on linumic.com — all three languages. + * + * Run it from a browser tab that is signed in to linumic.com/wp-admin: it needs + * the authenticated REST API to read the RAW block content. The rendered HTML + * that an anonymous request returns is not the same thing; posting that back + * would flatten the page's Gutenberg blocks and leave it uneditable. + * + * Dry run by default: it reports what it would change and writes nothing. + * Pass true to actually save. + * + * await updateBrochure(false) // show the diff + * await updateBrochure(true) // save + * + * Every replacement asserts the old text appears EXACTLY once. A page whose + * wording has drifted is skipped and reported rather than half-edited — the + * failure mode to avoid is a page that is neither the old version nor the new. + * + * --------------------------------------------------------------------------- + * APPLIED 2026-09-08, in two passes. + * + * Pass 1 (the set below) added the work-assignment feature card and a portal + * bullet, in all three languages. Its claims stopped at the portal on purpose: + * the employee half was built and the server live, but it only reaches a + * worker's phone in a signed APK, and none had shipped. Saying "staff open the + * app" that morning would have been false that morning. + * + * Pass 2 ran once WorkTrack 1.1.0 was published and verified on the download + * page. It added the app bullet to each list — + * EN
  • Today’s work, and the next working day
  • + * FA
  • کار امروز، و روز کاری بعد
  • + * PS
  • د نن کار، او راتلونکې کاري ورځ
  • + * and put the phone into the card body, replacing the clause that began "The + * portal shows" so it now reads "Staff open the app and see what they are on; + * the portal shows …". Both passes are live. + * + * The sets are kept here as a record of the published wording. Re-running them + * reports "wording drifted", which is the assertion doing its job, not a fault. + * + * An earlier set (per-person allowances, the licence sentence, the download + * button) was applied on 2026-09-07 and is not reproduced here. + * --------------------------------------------------------------------------- + */ + +/** The markup of one feature card, so a new one matches its neighbours exactly. */ +const CARD = (title, body) => + `
    ${title}
    ` + + `${body}
    `; + +// Anchors. Each new card is inserted BEFORE the leave card, so work assignment +// lands between "when people work" and "when they are off" — where a reader +// looking for it would go. +// +// The anchor is the leave card's OPENING tag, not the whole shifts card that +// precedes it, and that is deliberate: WordPress texturises on render, so the +// "night’s work" visible in the published HTML may be a plain apostrophe +// in the stored block. Anchoring on a fragment with no punctuation at all means +// the match does not depend on guessing which form is in the database. +const LEAVE_EN = '
    Leave with a real approval chain'; +const LEAVE_FA = '
    رخصتی با زنجیرهٔ تأیید واقعی'; +const LEAVE_PS = '
    رخصتي د تصویب ریښتینې لړۍ سره'; + +const CARD_EN = CARD( + "Who is on which part of the job", + "Assign a day’s work to one person or a whole crew, against the project it belongs to. The portal shows who is on what for any day, and each person’s next working day — which after a Thursday is Saturday, not an empty Friday.", +); + +const CARD_FA = CARD( + "چه کسی روی کدام بخش کار است", + "کار یک روز را به یک نفر یا به یک تیم بدهید، زیر پروژه‌ای که به آن تعلق دارد. پورتال نشان می‌دهد در هر روز چه کسی روی چه کاری است، و روز کاری بعدِ هر نفر — که بعد از پنجشنبه شنبه است، نه جمعهٔ خالی.", +); + +const CARD_PS = CARD( + "څوک د کار په کومه برخه دی", + "د یوې ورځې کار یو تن یا یو بشپړ ټیم ته وسپارئ، د هغې پروژې لاندې چې ورپورې اړه لري. پورټال ښیي چې په هره ورځ څوک په کوم کار دی، او د هر چا راتلونکې کاري ورځ — چې د پنجشنبې وروسته شنبه ده، نه تشه جمعه.", +); + +const EDITS = { + 2054: { + lang: "English", + replacements: [ + [LEAVE_EN, CARD_EN + LEAVE_EN], + [ + "
  • Payroll runs and payslips
  • ", + "
  • Payroll runs and payslips
  • Projects, crews, and who is on what today
  • ", + ], + ], + }, + 2055: { + lang: "Dari", + replacements: [ + [LEAVE_FA, CARD_FA + LEAVE_FA], + [ + "
  • اجرای معاش و فیش‌ها
  • ", + "
  • اجرای معاش و فیش‌ها
  • پروژه‌ها، تیم‌ها، و اینکه امروز چه کسی روی چه کاری است
  • ", + ], + ], + }, + 2056: { + lang: "Pashto", + replacements: [ + [LEAVE_PS, CARD_PS + LEAVE_PS], + [ + "
  • د معاش اجرا او فیشونه
  • ", + "
  • د معاش اجرا او فیشونه
  • پروژې، ټیمونه، او دا چې نن څوک په کوم کار دی
  • ", + ], + ], + }, +}; + +/** Landmarks that must survive every edit, or the page has been damaged. */ +const MUST_SURVIVE = ["lnm-section", "lnm-tier", "lnm-feat", "lnm-sechead-h"]; + +async function updateBrochure(apply = false) { + const nonce = window.wpApiSettings?.nonce ?? null; + const report = []; + + for (const [id, spec] of Object.entries(EDITS)) { + const res = await fetch(`/wp-json/wp/v2/pages/${id}?context=edit&_fields=content,title`, { + credentials: "include", + headers: nonce ? { "X-WP-Nonce": nonce } : {}, + }); + if (!res.ok) { + report.push({ id, lang: spec.lang, status: `cannot read (${res.status}) — sign in to wp-admin` }); + continue; + } + + const page = await res.json(); + const before = page.content.raw; + let after = before; + const applied = []; + const missing = []; + + for (const [oldText, newText] of spec.replacements) { + const count = after.split(oldText).length - 1; + if (count !== 1) { + missing.push({ count, snippet: oldText.slice(0, 60) }); + continue; + } + after = after.replace(oldText, newText); + applied.push(oldText.slice(0, 48)); + } + + if (missing.length) { + // Half-editing a live page is worse than not editing it. + report.push({ id, lang: spec.lang, status: "SKIPPED — wording drifted", missing }); + continue; + } + + const lost = MUST_SURVIVE.filter((k) => after.split(k).length < before.split(k).length); + if (lost.length) { + report.push({ id, lang: spec.lang, status: "SKIPPED — edit would remove structure", lost }); + continue; + } + + const delta = after.length - before.length; + if (delta < 0 || delta > 2000) { + report.push({ id, lang: spec.lang, status: `SKIPPED — implausible size change (${delta})` }); + continue; + } + + if (!apply) { + report.push({ id, lang: spec.lang, status: `would change (${applied.length} edits, +${delta} bytes)` }); + continue; + } + + const save = await fetch(`/wp-json/wp/v2/pages/${id}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json", ...(nonce ? { "X-WP-Nonce": nonce } : {}) }, + body: JSON.stringify({ content: after }), + }); + report.push({ + id, + lang: spec.lang, + status: save.ok ? `SAVED (+${delta} bytes)` : `save failed (${save.status})`, + }); + } + + return report; +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..27fe5d9 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,51 @@ +pluginManagement { + includeBuild("build-logic") + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + // Auto-provisions a matching JDK when the machine has none installed, + // instead of failing sync with NoToolchainAvailableException. + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "WorkTrack" + +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +include(":app") + +include(":core:common") +include(":core:model") +include(":core:domain") +include(":core:database") +include(":core:datastore") +include(":core:network") +include(":core:data") +include(":core:sync") +include(":core:designsystem") + +include(":feature:auth") +include(":feature:dashboard") +include(":feature:attendance") +include(":feature:leave") +include(":feature:payslips") +include(":feature:profile") diff --git a/web/.env.demo b/web/.env.demo new file mode 100644 index 0000000..1288f66 --- /dev/null +++ b/web/.env.demo @@ -0,0 +1,25 @@ +# --------------------------------------------------------------------------- +# Public demo tenant (worktrack-demo-af) — a SEPARATE Firebase project from +# production, so the published demo password can never exist in a system that +# holds a real company's attendance and pay. +# +# Build with it explicitly, and move .env.local aside first — Vite lets it +# override, which would put emulator config into the build: +# mv web/.env.local web/.env.local.off +# npm --prefix web run build -- --mode demo +# mv web/.env.local.off web/.env.local +# +# These VITE_* values are compiled into the bundle and are public by design; a +# Firebase web API key is an identifier, not a secret. +# --------------------------------------------------------------------------- + +# Served behind this project's own Hosting, which rewrites /v1/** to its `api` +# function — same relative-URL arrangement as production. +VITE_API_BASE_URL=/v1 + +VITE_FIREBASE_API_KEY=AIzaSyA1Kb5qR8UKXLTkpR3o0Qz7xPUT9i7wAxo +VITE_FIREBASE_AUTH_DOMAIN=worktrack-demo-af.firebaseapp.com +VITE_FIREBASE_PROJECT_ID=worktrack-demo-af +VITE_FIREBASE_APP_ID=1:1081764637667:web:a46dd31dc7424526e9ced6 + +VITE_USE_EMULATORS=false diff --git a/web/.env.emulator b/web/.env.emulator new file mode 100644 index 0000000..da649ba --- /dev/null +++ b/web/.env.emulator @@ -0,0 +1,15 @@ +# Local demo config — talks to the Firebase Emulator Suite, no real Firebase +# project needed. Copy this to .env.local to run the portal against the seeded +# demo tenant: cp .env.emulator .env.local + +VITE_USE_EMULATORS=true + +# Demo values are accepted by the Auth emulator as-is (project id must match the +# emulator project you start: --project demo-worktrack). +VITE_FIREBASE_API_KEY=demo-key +VITE_FIREBASE_AUTH_DOMAIN=demo-worktrack.firebaseapp.com +VITE_FIREBASE_PROJECT_ID=demo-worktrack +VITE_FIREBASE_APP_ID=demo-app + +# Functions emulator URL (project id in the path must match too): +VITE_API_BASE_URL=http://127.0.0.1:5001/demo-worktrack/us-central1/api/v1 diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..08964b0 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,11 @@ +# Copy to .env.local and fill in from your Firebase project settings. +# All VITE_-prefixed vars are exposed to the client bundle (public config only). + +# REST API base URL. Local Functions emulator by default: +VITE_API_BASE_URL=http://127.0.0.1:5001/worktrack-dev/us-central1/api/v1 + +# Firebase web app config (Project settings -> Your apps -> Web app): +VITE_FIREBASE_API_KEY= +VITE_FIREBASE_AUTH_DOMAIN= +VITE_FIREBASE_PROJECT_ID= +VITE_FIREBASE_APP_ID= diff --git a/web/.env.production b/web/.env.production new file mode 100644 index 0000000..8abb2c0 --- /dev/null +++ b/web/.env.production @@ -0,0 +1,24 @@ +# --------------------------------------------------------------------------- +# Production config for the WorkTrack manager portal (used by `npm run build`). +# Fill these from: Firebase console → Project settings → General → Your apps → +# (create a Web app) → SDK setup and configuration. +# +# IMPORTANT: `.env.local` (the emulator/demo config) can override this during a +# production build. Before `npm run build`, move it aside so it can't leak the +# demo values into your release: +# mv .env.local .env.local.disabled # build, deploy, then restore: +# mv .env.local.disabled .env.local +# (`.env.local` is just a copy of `.env.emulator`, so it is safe to move.) +# --------------------------------------------------------------------------- + +# Served behind Firebase Hosting, which rewrites /v1/** to the `api` function +# (see backend/firebase.json) — a relative base URL avoids any CORS setup. +VITE_API_BASE_URL=/v1 + +VITE_FIREBASE_API_KEY=AIzaSyBhGGgbBqhdsJYpM9FpQld28jyhvEfqWPA +VITE_FIREBASE_AUTH_DOMAIN=worktrack-prod.firebaseapp.com +VITE_FIREBASE_PROJECT_ID=worktrack-prod +VITE_FIREBASE_APP_ID=1:610243436336:web:c315170a68755212c032f5 + +# Production must NOT use the local emulators. +VITE_USE_EMULATORS=false diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..92ef5c7 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +.env +.env.local +*.local +*.tsbuildinfo + +# The demo build, produced by `npm run build -- --mode demo`. +dist-demo diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..376721d --- /dev/null +++ b/web/README.md @@ -0,0 +1,51 @@ +# WorkTrack Manager Portal (پورتال مدیر) + +Web admin console for managers, HR, payroll, and branch/team leads. React 18 + +TypeScript + Vite, consuming the same `/v1` REST API as the Android app. Dari is +the default language (full Pashto + English), RTL-first, with the Solar Hijri +calendar throughout. + +## Features (P0) + +- **Login** — Firebase email/password; only manager roles are admitted + (`COMPANY_ADMIN`, `HR_ADMIN`, `PAYROLL_ADMIN`, `BRANCH_MANAGER`, `TEAM_LEAD`, + `AUDITOR`). Employees/kiosks are rejected. +- **Dashboard** — today's KPIs (active, present, absent, on-leave, late, half-day, + pending leave, attendance rate) + a 7-day Solar Hijri attendance trend. +- **Employees** — directory (branch-scoped for branch managers), search, and an + add-employee form (`employees:write`). +- **Attendance monitoring** — per-day live board of every employee's status, + first-in time, worked hours, and lateness; date picker in Solar Hijri. +- **Leave approvals** — pending-request queue with approve/reject (rejection + requires a note, enforced server-side too). +- **Payroll** — run payroll for a Solar Hijri month (basic + earning components − + deductions − loss-of-pay from attendance), then view the generated payslips per + employee. Amounts in AFN; period is Shamsi. Gated on `payroll:read` / `payroll:run`. + +RBAC gates the sidebar and actions client-side for UX; the server is authoritative. + +## Develop + +```bash +npm install +cp .env.example .env.local # fill in Firebase web config + API base URL +npm run dev # http://localhost:5173 +npm run build # tsc + vite build -> dist/ +``` + +`.env.local` needs your Firebase **web app** config (Project settings → Your apps → +Web) and `VITE_API_BASE_URL`. For local development point it at the Functions +emulator, e.g. `http://127.0.0.1:5001//us-central1/api/v1`. + +## Deploy (Firebase Hosting) + +Hosting is configured in `../backend/firebase.json` (serves `web/dist`, rewrites +`/v1/**` to the `api` function and everything else to the SPA): + +```bash +npm run build +cd ../backend && firebase deploy --only hosting +``` + +When served from Hosting you can set `VITE_API_BASE_URL=/v1` so the SPA and API +share an origin (no CORS). diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..651983e --- /dev/null +++ b/web/index.html @@ -0,0 +1,34 @@ + + + + + + + WorkTrack — پورتال مدیر + + + + + +
    + + + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..d59c6d5 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,4230 @@ +{ + "name": "worktrack-admin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "worktrack-admin", + "version": "1.0.0", + "dependencies": { + "@tanstack/react-query": "^5.51.1", + "firebase": "^10.12.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.25.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/qrcode": "^1.5.6", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "jsdom": "^29.1.1", + "qrcode": "^1.5.4", + "typescript": "^5.5.4", + "vite": "^5.3.4", + "vitest": "^2.1.9" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.8.tgz", + "integrity": "sha512-CVnHcS4iRJPqtIDc411+UmFldk0ShSK3OB+D0bKD8Ck5Vro6dbK5+APZpkuWpbfdL359DIQUnAaMLE+zs/PVyA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/installations": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.14.tgz", + "integrity": "sha512-unRVY6SvRqfNFIAA/kwl4vK+lvQAL2HVcgu9zTrUtTyYDmtIt/lOuHJynBMYEgLnKm39YKBDhtqdapP2e++ASw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.8", + "@firebase/analytics-types": "0.8.2", + "@firebase/component": "0.6.9", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.2.tgz", + "integrity": "sha512-EnzNNLh+9/sJsimsA/FGqzakmrAUKLeJvjRHlg8df1f97NLUlFidk9600y0ZgWOp3CAxn6Hjtk+08tixlUOWyw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.10.13", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.10.13.tgz", + "integrity": "sha512-OZiDAEK/lDB6xy/XzYAyJJkaDqmQ+BCtOEPLqFvxWKUz5JbBmej7IiiRHdtiIOD/twW7O5AxVsfaaGA/V1bNsA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.8.8.tgz", + "integrity": "sha512-O49RGF1xj7k6BuhxGpHmqOW5hqBIAEbt2q6POW0lIywx7emYtzPDeQI+ryQpC4zbKX646SoVZ711TN1DBLNSOQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.3.15.tgz", + "integrity": "sha512-zFIvIFFNqDXpOT2huorz9cwf56VT3oJYRFjSFYdSbGYEJYEaXjLJbfC79lx/zjx4Fh+yuN8pry3TtvwaevrGbg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.8.8", + "@firebase/app-check-types": "0.5.2", + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.2.tgz", + "integrity": "sha512-LMs47Vinv2HBMZi49C09dJxp0QT5LwDzFaVGf/+ITHe3BlIhUiLNttkATSXplc89A2lAaeTqjgqVkiRfUGyQiQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.2.tgz", + "integrity": "sha512-FSOEzTzL5bLUbD2co3Zut46iyPWML6xc4x+78TeaXMSuJap5QObfb+rVvZJtla3asN4RwU7elaQaduP+HFizDA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.2.43", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.2.43.tgz", + "integrity": "sha512-HM96ZyIblXjAC7TzE8wIk2QhHlSvksYkQ4Ukh1GmEenzkucSNUmUX4QvoKrqeWsLEQ8hdcojABeCV8ybVyZmeg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app": "0.10.13", + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.2.tgz", + "integrity": "sha512-oMEZ1TDlBz479lmABwWsWjzHwheQKiAgnuKxE0pz0IXCVx7/rtlkx1fQ6GfgK24WCrxDKMplZrT50Kh04iMbXQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-compat": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.5.14.tgz", + "integrity": "sha512-2eczCSqBl1KUPJacZlFpQayvpilg3dxXLy9cSMTKtQMTQSmondUtPI47P3ikH3bQAXhzKLOE+qVxJ3/IRtu9pw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.7.9", + "@firebase/auth-types": "0.12.2", + "@firebase/component": "0.6.9", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0", + "undici": "6.19.7" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-compat/node_modules/@firebase/auth": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.7.9.tgz", + "integrity": "sha512-yLD5095kVgDw965jepMyUrIgDklD6qH/BZNHeKOgvu7pchOKNjVM+zQoOVYJIKWMWOWBq8IRNVU6NXzBbozaJg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0", + "undici": "6.19.7" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^1.18.1" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.3.tgz", + "integrity": "sha512-Fc9wuJGgxoxQeavybiuwgyi+0rssr76b+nHpj+eGhXFYAdudMWyfBHvFL/I5fEHniUM/UQdFzi9VXJK2iZF7FQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.12.2.tgz", + "integrity": "sha512-qsEBaRMoGvHO10unlDJhaKSuPn4pyoTtlQuP1ghZfzB6rNQPuhp/N/DcFZxm9i4v0SogjCbf9reWupwIvfmH6w==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.9.tgz", + "integrity": "sha512-gm8EUEJE/fEac86AvHn8Z/QW8BvR56TBw3hMW0O838J/1mThYQXAIQBgUv75EqlCZfdawpWLrKt1uXvp9ciK3Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.1.0.tgz", + "integrity": "sha512-vSe5s8dY13ilhLnfY0eYRmQsdTbH7PUFZtBbqU6JVX/j8Qp9A6G5gG6//ulbX9/1JFOF1IWNOne9c8S/DOCJaQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.3", + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.8.tgz", + "integrity": "sha512-dzXALZeBI1U5TXt6619cv0+tgEhJiwlUtQ55WNZY7vGAjv7Q1QioV969iYwt1AQQ0ovHnEW0YW9TiBfefLvErg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.2", + "@firebase/auth-interop-types": "0.2.3", + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-1.0.8.tgz", + "integrity": "sha512-OpeWZoPE3sGIRPBKYnW9wLad25RaWbGyk7fFQe4xnJQKRzlynWeFBSRRAoLE2Old01WXwskUiucNqUUVlFsceg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/database": "1.0.8", + "@firebase/database-types": "1.0.5", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.5.tgz", + "integrity": "sha512-fTlqCNwFYyq/C6W7AJ5OCuq5CeZuBEsEwptnVxlNPkWCo5cTTyukzAHRSO/jaQcItz33FfYrrFk1SJofcu2AaQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.2", + "@firebase/util": "1.10.0" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.7.3.tgz", + "integrity": "sha512-NwVU+JPZ/3bhvNSJMCSzfcBZZg8SUGyzZ2T0EW3/bkUeefCyzMISSt/TTIfEHc8cdyXGlMqfGe3/62u9s74UEg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "@firebase/webchannel-wrapper": "1.0.1", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "tslib": "^2.1.0", + "undici": "6.19.7" + }, + "engines": { + "node": ">=10.10.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.3.38", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.3.38.tgz", + "integrity": "sha512-GoS0bIMMkjpLni6StSwRJarpu2+S5m346Na7gr9YZ/BZ/W3/8iHGNr9PxC+f0rNZXqS4fGRn88pICjrZEgbkqQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/firestore": "4.7.3", + "@firebase/firestore-types": "3.0.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.2.tgz", + "integrity": "sha512-wp1A+t5rI2Qc/2q7r2ZpjUXkRVPtGMd6zCLsiWurjsQpqPgFin3AhNibKcIzoF2rnToNa/XYtyWXuifjOOwDgg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.11.8.tgz", + "integrity": "sha512-Lo2rTPDn96naFIlSZKVd1yvRRqqqwiJk7cf9TZhUerwnPKgBzXy+aHE22ry+6EjCaQusUoNai6mU6p+G8QZT1g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.2", + "@firebase/auth-interop-types": "0.2.3", + "@firebase/component": "0.6.9", + "@firebase/messaging-interop-types": "0.2.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0", + "undici": "6.19.7" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.3.14.tgz", + "integrity": "sha512-dZ0PKOKQFnOlMfcim39XzaXonSuPPAVuzpqA4ONTIdyaJK/OnBaIEVs/+BH4faa1a2tLeR+Jy15PKqDRQoNIJw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/functions": "0.11.8", + "@firebase/functions-types": "0.6.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.2.tgz", + "integrity": "sha512-0KiJ9lZ28nS2iJJvimpY4nNccV21rkQyor5Iheu/nq8aKXJqtJdeSlZDspjPSBBiHRzo7/GMUttegnsEITqR+w==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.9.tgz", + "integrity": "sha512-hlT7AwCiKghOX3XizLxXOsTFiFCQnp/oj86zp1UxwDGmyzsyoxtX+UIZyVyH/oBF5+XtblFG9KZzZQ/h+dpy+Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/util": "1.10.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.9.tgz", + "integrity": "sha512-2lfdc6kPXR7WaL4FCQSQUhXcPbI7ol3wF+vkgtU25r77OxPf8F/VmswQ7sgIkBBWtymn5ZF20TIKtnOj9rjb6w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/installations": "0.6.9", + "@firebase/installations-types": "0.5.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.2.tgz", + "integrity": "sha512-que84TqGRZJpJKHBlF2pkvc1YcXrtEDOVGiDjovP/a3s6W4nlbohGXEsBJo0JCeeg/UG9A+DEZVDUV9GpklUzA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.2.tgz", + "integrity": "sha512-Q1VuA5M1Gjqrwom6I6NUU4lQXdo9IAQieXlujeHZWvRt1b7qQ0KwBaNAjgxG27jgF9/mUwsNmO8ptBCGVYhB0A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.12", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.12.tgz", + "integrity": "sha512-6q0pbzYBJhZEtUoQx7hnPhZvAbuMNuBXKQXOx2YlWhSrlv9N1m0ZzlNpBbu/ItTzrwNKTibdYzUyaaxdWLg+4w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/installations": "0.6.9", + "@firebase/messaging-interop-types": "0.2.2", + "@firebase/util": "1.10.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.12.tgz", + "integrity": "sha512-pKsiUVZrbmRgdImYqhBNZlkKJbqjlPkVdQRZGRbkTyX4OSGKR0F/oJeCt1a8jEg5UnBp4fdVwSWSp4DuCovvEQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/messaging": "0.12.12", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.2.tgz", + "integrity": "sha512-l68HXbuD2PPzDUOFb3aG+nZj5KA3INcPwlocwLZOzPp9rFM9yeuI9YLl6DQfguTX5eAGxO0doTR+rDLDvQb5tA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.6.9.tgz", + "integrity": "sha512-PnVaak5sqfz5ivhua+HserxTJHtCar/7zM0flCX6NkzBNzJzyzlH4Hs94h2Il0LQB99roBqoE5QT1JqWqcLJHQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/installations": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.9.tgz", + "integrity": "sha512-dNl95IUnpsu3fAfYBZDCVhXNkASE0uo4HYaEPd2/PKscfTvsgqFAOxfAXzBEDOnynDWiaGUnb5M1O00JQ+3FXA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/performance": "0.6.9", + "@firebase/performance-types": "0.2.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.2.tgz", + "integrity": "sha512-gVq0/lAClVH5STrIdKnHnCo2UcPLjJlDUoEB/tB4KM+hAeHUxWKnpT0nemUPvxZ5nbdY/pybeyMe8Cs29gEcHA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.4.9.tgz", + "integrity": "sha512-EO1NLCWSPMHdDSRGwZ73kxEEcTopAxX1naqLJFNApp4hO8WfKfmEpmjxmP5TrrnypjIf2tUkYaKsfbEA7+AMmA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/installations": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.9.tgz", + "integrity": "sha512-AxzGpWfWFYejH2twxfdOJt5Cfh/ATHONegTd/a0p5flEzsD5JsxXgfkFToop+mypEL3gNwawxrxlZddmDoNxyA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/remote-config": "0.4.9", + "@firebase/remote-config-types": "0.3.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.3.2.tgz", + "integrity": "sha512-0BC4+Ud7y2aPTyhXJTMTFfrGGLqdYXrUB9sJVAB8NiqJswDTc4/2qrE/yfUbnQJhbSi6ZaTTBKyG3n1nplssaA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.13.2.tgz", + "integrity": "sha512-fxuJnHshbhVwuJ4FuISLu+/76Aby2sh+44ztjF2ppoe0TELIDxPW6/r1KGlWYt//AD0IodDYYA8ZTN89q8YqUw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0", + "undici": "6.19.7" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.3.12.tgz", + "integrity": "sha512-hA4VWKyGU5bWOll+uwzzhEMMYGu9PlKQc1w4DWxB3aIErWYzonrZjF0icqNQZbwKNIdh8SHjZlFeB2w6OSsjfg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/storage": "0.13.2", + "@firebase/storage-types": "0.8.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.2.tgz", + "integrity": "sha512-0vWu99rdey0g53lA7IShoA2Lol1jfnPovzLDUBuon65K7uKG9G+L5uO05brD9pMw+l4HRFw23ah3GwTGpEav6g==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.10.0.tgz", + "integrity": "sha512-xKtx4A668icQqoANRxyDLBLz51TAbDP9KRfpbKGxiCAW346d0BeJe5vN6/hKxxmWwnZ0mautyv39JxviwwQMOQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/vertexai-preview": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@firebase/vertexai-preview/-/vertexai-preview-0.0.4.tgz", + "integrity": "sha512-EBSqyu9eg8frQlVU9/HjKtHN7odqbh9MtAcVz3WwHj4gLCLOoN9F/o+oxlq3CxvFrd3CNTZwu6d2mZtVlEInng==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.2", + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.1.tgz", + "integrity": "sha512-jmEnr/pk0yVkA7mIlHNnxCi+wWzOFUg0WyIotgkKAb2u1J7fAeDBcVNSTjTihbAYNusCLQdW5s9IJ5qwnEufcQ==", + "license": "Apache-2.0" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", + "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "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", + "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/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.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" + }, + "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" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "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" + }, + "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" + }, + "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" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "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", + "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/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.393", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", + "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", + "dev": true, + "license": "ISC" + }, + "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/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/firebase": { + "version": "10.14.1", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-10.14.1.tgz", + "integrity": "sha512-0KZxU+Ela9rUCULqFsUUOYYkjh7OM1EWdIfG6///MtXd0t2/uUIf0iNV5i0KariMhRQ5jve/OY985nrAXFaZeQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.8", + "@firebase/analytics-compat": "0.2.14", + "@firebase/app": "0.10.13", + "@firebase/app-check": "0.8.8", + "@firebase/app-check-compat": "0.3.15", + "@firebase/app-compat": "0.2.43", + "@firebase/app-types": "0.9.2", + "@firebase/auth": "1.7.9", + "@firebase/auth-compat": "0.5.14", + "@firebase/data-connect": "0.1.0", + "@firebase/database": "1.0.8", + "@firebase/database-compat": "1.0.8", + "@firebase/firestore": "4.7.3", + "@firebase/firestore-compat": "0.3.38", + "@firebase/functions": "0.11.8", + "@firebase/functions-compat": "0.3.14", + "@firebase/installations": "0.6.9", + "@firebase/installations-compat": "0.2.9", + "@firebase/messaging": "0.12.12", + "@firebase/messaging-compat": "0.2.12", + "@firebase/performance": "0.6.9", + "@firebase/performance-compat": "0.2.9", + "@firebase/remote-config": "0.4.9", + "@firebase/remote-config-compat": "0.2.9", + "@firebase/storage": "0.13.2", + "@firebase/storage-compat": "0.3.12", + "@firebase/util": "1.10.0", + "@firebase/vertexai-preview": "0.0.4" + } + }, + "node_modules/firebase/node_modules/@firebase/auth": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.7.9.tgz", + "integrity": "sha512-yLD5095kVgDw965jepMyUrIgDklD6qH/BZNHeKOgvu7pchOKNjVM+zQoOVYJIKWMWOWBq8IRNVU6NXzBbozaJg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0", + "undici": "6.19.7" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^1.18.1" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "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" + }, + "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" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true, + "license": "ISC" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "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/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "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/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-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.19.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.7.tgz", + "integrity": "sha512-HR3W/bMGPSr90i8AAp2C4DM3wChFdJPLrWYpIS++LxS8K+W535qftjt+4MyjNYHeWabMj1nvtmLIi7l++iq91A==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "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-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "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", + "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/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "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", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "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", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..e981a54 --- /dev/null +++ b/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "worktrack-admin", + "private": true, + "version": "1.0.0", + "type": "module", + "description": "WorkTrack manager portal (web admin) — React + TypeScript", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@tanstack/react-query": "^5.51.1", + "firebase": "^10.12.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.25.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/qrcode": "^1.5.6", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "jsdom": "^29.1.1", + "qrcode": "^1.5.4", + "typescript": "^5.5.4", + "vite": "^5.3.4", + "vitest": "^2.1.9" + } +} diff --git a/web/public/landing/img/icon-128.png b/web/public/landing/img/icon-128.png new file mode 100644 index 0000000..7ebb73d Binary files /dev/null and b/web/public/landing/img/icon-128.png differ diff --git a/web/public/landing/img/screenshot-01-work.png b/web/public/landing/img/screenshot-01-work.png new file mode 100644 index 0000000..e2201ed Binary files /dev/null and b/web/public/landing/img/screenshot-01-work.png differ diff --git a/web/public/landing/img/screenshot-02-checkin.png b/web/public/landing/img/screenshot-02-checkin.png new file mode 100644 index 0000000..bbde0f0 Binary files /dev/null and b/web/public/landing/img/screenshot-02-checkin.png differ diff --git a/web/public/landing/img/screenshot-03-leave.png b/web/public/landing/img/screenshot-03-leave.png new file mode 100644 index 0000000..3b9a340 Binary files /dev/null and b/web/public/landing/img/screenshot-03-leave.png differ diff --git a/web/public/landing/img/screenshot-04-profile.png b/web/public/landing/img/screenshot-04-profile.png new file mode 100644 index 0000000..688cdf1 Binary files /dev/null and b/web/public/landing/img/screenshot-04-profile.png differ diff --git a/web/public/landing/index.html b/web/public/landing/index.html new file mode 100644 index 0000000..9f5700d --- /dev/null +++ b/web/public/landing/index.html @@ -0,0 +1,610 @@ + + + + + +ورک‌ترک — حاضری و معاش، بدون کاغذ + + + + + + + + + + + + + + + + +
    + +
    +
    +

    کارمندت کجاست؟
    ورک‌ترک می‌دانه.

    +

    + حاضری با موبایل، تشخیص چهره، رخصتی و معاش — همه‌اش در یک اپ. + به دری و پشتو، با تقویم حمل و ثور و جوزا. +

    + +
    +
    + +
    +
    +
    حاضری
    +
    ثبت ورود
    +
    رخصتی
    +
    پروفایل
    +
    +
    + +
    +
    +

    دفتر حاضری کاغذی؟ دیگه لازم نیست.

    +

    + کارمند موبایلش را باز می‌کنه، دکمه را می‌زنه — تمام. + ورک‌ترک ساعت و موقعیت را ثبت می‌کنه. اگه بیرون از ساحهٔ کاری باشه، علامت می‌خوره و سرپرست خبر می‌شه. +

    +

    + کسی هم نمی‌تانه به جای دیگری حاضری بزنه — تشخیص چهره مطمئن می‌شه که خود آدم است. + اینترنت هم نباشه مشکلی نیست؛ حاضری روی گوشی ذخیره می‌شه و بعداً می‌ره. +

    +
    +
    + +
    +
    +

    رخصتی و معاش — بدون سردرد

    +

    + کارمند از اپ درخواست رخصتی می‌ده. سرپرست تأیید یا رد می‌کنه. + ماندهٔ رخصتی خودش حساب می‌شه — لازم نیست کسی جمع و تفریق کنه. +

    +

    + آخر ماه، ورک‌ترک معاش هر کارمند را محاسبه می‌کنه — ماهانه، روزمزد، یا کارمزد. + مالیات، کسرات، اضافه‌کاری — همه‌اش خودکار. فیش معاش آماده، فایل اکسل هم می‌شه گرفت. +

    +
    +
    + +
    +
    +

    برای اینجا ساخته شده، نه ترجمه شده

    +

    + ورک‌ترک یک اپ خارجی نیست که فارسیش کرده باشند. + از اول برای افغانستان طراحی شده: +

    +
    + دری و پشتو — نه فقط انگلیسی + تقویم حمل، ثور، جوزا + واحد پول: افغانی + رخصتی هفته‌وار: جمعه +
    +
    +
    + +
    +
    +

    مدیر هستید؟ پورتال وب هم دارید.

    +

    + لازم نیست اپ نصب کنید. از کمپیوتر وارد شوید — داشبورد، لیست کارمندان، گزارش حاضری و معاشات. + هر شرکت فضای جدای خودش را داره؛ اطلاعات‌تان با هیچ‌کس قاطی نمی‌شه. +

    +
    +
    +

    از ۲ نفر تا ۲۰۰ نفر

    +

    فرق نمی‌کنه دفتر کوچک دارید یا چند شعبه. ورک‌ترک بزرگ و کوچک را یکسان اداره می‌کنه.

    +
    +
    +

    اطلاعات پیش خودتان

    +

    حاضری، معاش، مدارک کارمندان — همه‌اش امن و فقط مال شما. کسی دیگه دسترسی نداره.

    +
    +
    +
    +
    + +
    +
    +

    اپ را بگیرید. کارفرمای‌تان حساب‌تان را فعال می‌کنه.

    +

    اگر کارفرما هستید و می‌خواهید ورک‌ترک را امتحان کنید، برایمان بنویسید.

    + +
    +
    + +
    + + + + + + + + + + + + + + + + + diff --git a/web/public/privacy/index.html b/web/public/privacy/index.html new file mode 100644 index 0000000..cb324bc --- /dev/null +++ b/web/public/privacy/index.html @@ -0,0 +1,413 @@ + + + + + + +سیاست حریم خصوصی — ورک‌ترک + + + +
    + +
    + + + +
    + + +
    +
    +

    سیاست حریم خصوصی ورک‌ترک

    +
    آخرین به‌روزرسانی: ۱۸ سنبله ۱۴۰۵ (۸ سپتمبر ۲۰۲۶)
    +
    + +
    + خلاصه در چند خط: ورک‌ترک ابزار کاری است که کارفرمای شما آن را خریده. + داده‌های شما مال کارفرمای شماست، نه مال ما. ما به‌عنوان سازندهٔ نرم‌افزار هیچ استفادهٔ + دیگری از آن نمی‌کنیم — نه تبلیغات، نه فروش، نه تحلیل رفتار. عکس چهرهٔ شما هرگز + از گوشی خارج نمی‌شود. موقعیت مکانی فقط در همان لحظه‌ای که حاضری می‌زنید خوانده + می‌شود، نه در پس‌زمینه. +
    + +

    ۱. چه کسی مسئول است

    +

    + کارفرمای شما تصمیم می‌گیرد چه داده‌ای جمع شود و چه مدت نگه داشته شود. + ورک‌ترک (لینومیک) فقط نرم‌افزار و سرور را فراهم می‌کند و داده را از طرف + کارفرما نگه می‌دارد. اگر می‌خواهید داده‌تان اصلاح یا حذف شود، اول با کارفرمای خود تماس + بگیرید — دسترسی و اختیار حذف دست اوست، نه ما. +

    + +

    ۲. چه داده‌ای جمع می‌شود

    +
      +
    • شناسایی: نام، ایمیل، شمارهٔ تماس، کد کارمندی، شعبه و سمت. این‌ها را + کارفرما وارد می‌کند، نه شما.
    • +
    • حاضری: زمان ورود و خروج، رخصتی، و درخواست‌های اصلاح.
    • +
    • موقعیت مکانی: مختصات و دقت آن، فقط در همان ثانیه‌ای که دکمهٔ + ثبت ورود یا خروج را می‌زنید. برنامه در پس‌زمینه شما را دنبال نمی‌کند و اجازهٔ + موقعیت دائمی هم نمی‌گیرد. هدفش یک چیز است: معلوم شود در ساحهٔ کاری بودید یا نه.
    • +
    • چهره (اگر کارفرما فعالش کرده باشد): پایین جداگانه توضیح داده شده.
    • +
    • معاش: فیش‌ها و اجزای معاش، اگر کارفرما این بخش را استفاده کند.
    • +
    + +

    ۳. چهره — دقیقاً چه اتفاقی می‌افتد

    +
    + عکس شما از گوشی بیرون نمی‌رود و در گوشی هم ذخیره نمی‌شود. +
    +

    + وقتی چهره‌تان را ثبت می‌کنید، برنامه روی خودِ گوشی عکس را به یک رشته از ۱۹۲ عدد + تبدیل می‌کند. فقط همین اعداد به سرور می‌روند. عکس همان‌جا در حافظهٔ گوشی از بین می‌رود. +

    +

    + هنگام حاضری، دوباره همین کار تکرار می‌شود و سرور فقط دو رشته عدد را با هم مقایسه می‌کند تا + بگوید «همان شخص است» یا «نیست». از این اعداد نمی‌شود عکس شما را دوباره ساخت، و برای هیچ کار + دیگری — شناسایی در جای دیگر، تحلیل، فروش — استفاده نمی‌شود. +

    +

    + اگر با اپ اندروید کار می‌کنید، کارفرما می‌تواند به‌طور جداگانه گزینهٔ + «عکس هنگام ورود» را روشن کند. در آن حالت یک عکس کوچک همراه حاضری ذخیره می‌شود و سرپرست + می‌تواند ببیندش. این با تشخیص چهره فرق دارد، اختیاری است، و در اپ آیفون اصلاً وجود + ندارد. اگر مطمئن نیستید کارفرمای‌تان آن را روشن کرده یا نه، از او بپرسید. +

    + +

    ۴. چه چیزی جمع نمی‌شود

    +
      +
    • هیچ ابزار تحلیلی، ردیابی، یا تبلیغاتی در برنامه نیست. رفتار شما در برنامه به هیچ شرکت + دیگری فرستاده نمی‌شود.
    • +
    • موقعیت مکانی در پس‌زمینه خوانده نمی‌شود.
    • +
    • به مخاطبین، گالری عکس، پیام‌ها یا فایل‌های گوشی شما دسترسی گرفته نمی‌شود.
    • +
    • داده‌های شما به هیچ‌کس فروخته نمی‌شود.
    • +
    + +

    ۵. کجا نگهداری می‌شود و چه کسی می‌بیند

    +

    + داده روی زیرساخت گوگل کلاود (Firebase) در ایالات متحده نگهداری می‌شود و در + انتقال و در حالت ذخیره رمزنگاری شده است. +

    +

    + هیچ برنامه‌ای — نه اپ موبایل و نه پورتال — اجازهٔ دسترسی مستقیم به پایگاه داده را ندارد؛ + همه چیز از یک API با احراز هویت می‌گذرد که نقش هر کاربر را بررسی می‌کند. کارمند فقط دادهٔ + خودش را می‌بیند. سرپرست فقط کارمندان شعبهٔ خودش را. کارهای حساس مثل بازنشانی چهره در دفتر + رویدادها ثبت می‌شود. +

    +

    + کارکنان لینومیک برای پشتیبانی فقط وقتی به دادهٔ یک شرکت دسترسی پیدا می‌کنند که برای رفع یک + مشکل لازم باشد. +

    + +

    ۶. چه مدت نگه داشته می‌شود

    +

    + تا زمانی که کارفرمای شما حسابش را فعال نگه دارد. حذف خودکار زمان‌دار وجود ندارد، + چون سوابق حاضری و معاش معمولاً باید سال‌ها نگهداری شوند. +

    +
      +
    • کارفرما می‌تواند در هر زمان ثبت چهرهٔ یک کارمند را پاک کند.
    • +
    • وقتی کارفرما حسابش را می‌بندد، حساب فوراً معلق می‌شود و پس از ۳۰ روز + مهلت انصراف، همه چیز برای همیشه پاک می‌شود.
    • +
    + +

    ۷. حقوق شما

    +

    + برای دیدن، اصلاح یا حذف داده‌تان با کارفرمای خود تماس بگیرید. اگر او پاسخ + نداد یا فکر می‌کنید داده‌ای نادرست نگهداری می‌شود، به آدرس زیر بنویسید. +

    + +

    ۸. تماس

    +

    لینومیک — contact@linumic.com

    + +
    + اگر این سیاست تغییر کند، تاریخ بالای صفحه عوض می‌شود و برای تغییرات مهم به کارفرمایان اطلاع + داده می‌شود. +
    +
    + + + + + + + +
    + + + + diff --git a/web/public/support/index.html b/web/public/support/index.html new file mode 100644 index 0000000..5183a33 --- /dev/null +++ b/web/public/support/index.html @@ -0,0 +1,235 @@ + + + + + + +پشتیبانی — ورک‌ترک + + + +
    + +
    + + + +
    + +
    +
    +

    پشتیبانی ورک‌ترک

    +
    کمک برای کارمندان و برای شرکت‌ها
    +
    + +
    + اول این را بخوانید: ورک‌ترک را کارفرمای شما خریده و اداره می‌کند. حساب شما، + ساحهٔ کاری، ساعت کار و معاش — همه را او تنظیم می‌کند، نه ما. برای بیشتر + مشکل‌ها، سریع‌ترین راه گفتن به سرپرست یا مدیر منابع بشری‌تان است. +
    + +

    پرسش‌های رایج کارمندان

    + +

    وارد اپ نمی‌شوم

    +

    در ورک‌ترک ثبت‌نام خودکار وجود ندارد. حساب شما را کارفرمای‌تان می‌سازد و + ایمیل و رمز اول را به شما می‌دهد. اگر حسابی ندارید، از مدیرتان بخواهید بسازد.

    + +

    رمزم را فراموش کرده‌ام

    +

    مدیرتان می‌تواند در پورتال رمز تازه بدهد: بخش کارمندان ← ویرایش ← حساب ورود. + ما رمز کسی را نمی‌بینیم و نمی‌توانیم بازنشانی کنیم.

    + +

    می‌گوید «بیرون از ساحهٔ کاری»

    +

    یعنی موقعیت گوشی شما هنگام ثبت حاضری، بیرون از محدوده‌ای بوده که کارفرما تعیین کرده. + حاضری ثبت می‌شود ولی علامت می‌خورد و سرپرست می‌بیندش. اگر در ساحه بودید و باز + هم این را گفت، معمولاً GPS ضعیف بوده — به سرپرست بگویید و درخواست اصلاح بدهید.

    + +

    می‌گوید حاضری‌ام حساب نشد

    +

    حاضری‌ای که بیرون از ساحه ثبت شود، تا سرپرست تأییدش نکند در محاسبهٔ وقت کاری نمی‌آید. + در همان صفحهٔ حاضری روی روز مورد نظر «درخواست اصلاح» را بزنید و ساعت درست را + بنویسید. حاضری اصلی پاک نمی‌شود؛ سرپرست هر دو را می‌بیند.

    + +

    فیش معاشم اشتباه است

    +

    فیش را حسابداری شرکت شما می‌سازد، نه ما. تفکیک کامل — عواید، کسرات، مالیه — در همان صفحهٔ + معاش نشان داده می‌شود. اگر رقمی درست نیست، آن صفحه را به حسابدارتان نشان بدهید.

    + +

    اینترنت ندارم

    +

    حاضری روی گوشی ذخیره می‌شود و وقتی شبکه آمد خودش فرستاده می‌شود. ساعت ثبت‌شده، + ساعتی است که دکمه را زدید، نه ساعتی که به سرور رسید — پس شبکهٔ ضعیف بخشی از روزتان را + نمی‌خورد.

    + +

    برای شرکت‌ها

    +

    برای گرفتن ورک‌ترک، تمدید لایسنس، افزودن دستگاه، یا مشکل فنی در پورتال، به ما بنویسید. + در پیام‌تان نام شرکت را بنویسید تا زودتر پیدایش کنیم.

    + +

    تماس با ما

    +

    contact@linumic.com

    +

    معمولاً ظرف یک روز کاری جواب می‌دهیم. + رمز عبورتان را هرگز برای ما نفرستید — ما هیچ‌وقت آن را نمی‌خواهیم.

    + + +
    + + + + + +
    + + + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..67fcd0b --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,68 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import { useAuth, useHasPermission } from "./auth/AuthProvider"; +import { LoginPage } from "./auth/LoginPage"; +import { Layout } from "./ui/Layout"; +import { LoadingState } from "./ui/components"; +import { DashboardPage } from "./pages/DashboardPage"; +import { EmployeesPage } from "./pages/EmployeesPage"; +import { AttendancePage } from "./pages/AttendancePage"; +import { ShiftsPage } from "./pages/ShiftsPage"; +import { WorkPage } from "./pages/WorkPage"; +import { LeavePage } from "./pages/LeavePage"; +import { PayrollPage } from "./pages/PayrollPage"; +import { FinancePage } from "./pages/FinancePage"; +import { SettingsPage } from "./pages/SettingsPage"; +import { KioskPage } from "./pages/KioskPage"; +import { DevicesPage } from "./pages/DevicesPage"; +import { VendorConsole } from "./pages/VendorConsole"; + +export function App() { + const { status } = useAuth(); + const can = useHasPermission(); + + if (status === "loading") { + return ; + } + if (status === "signedOut") { + return ; + } + // Linumic staff get their own console, not the customer portal — they have + // no company, and every tenant route would refuse their token anyway. + if (status === "vendor") { + return ; + } + // A dedicated kiosk device is locked to the full-screen check-in display. + if (status === "kiosk") { + return ; + } + + return ( + + {/* Kiosk mode runs full-screen, outside the portal chrome. */} + } /> + }> + {/* + The dashboard is the company's numbers — headcount, who is present, + the attendance trend — and every one of them needs attendance:read. + An employee landing there would meet a page of failed requests, so + they land on their own work instead, which is the only thing the + portal has for them. + */} + : } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/web/src/api/businessTypes.ts b/web/src/api/businessTypes.ts new file mode 100644 index 0000000..76fca14 --- /dev/null +++ b/web/src/api/businessTypes.ts @@ -0,0 +1,31 @@ +/** + * The business types offered at signup, in the order they are shown. + * + * Ids mirror backend/functions/src/services/businessTypes.ts — that file is + * where the defaults live and where the reasoning is written down. This one + * exists only so the list can be shown and translated; nothing here decides + * anything. + * + * A type the server does not recognise is ignored rather than refused, so the + * two lists drifting apart costs a customer nothing worse than the product + * defaults. + */ +export const BUSINESS_TYPES = [ + "OFFICE", + "CONSTRUCTION", + "RETAIL", + "TAILORING", + "WAREHOUSE", + "SECURITY", + "RESTAURANT", + "CLINIC", + "SCHOOL", + "NGO", + "EXCHANGE", + "TRANSPORT", + "PRODUCTION", + "AGRICULTURE", + "HOSPITALITY", +] as const; + +export type BusinessType = (typeof BUSINESS_TYPES)[number]; diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 0000000..9eadcbc --- /dev/null +++ b/web/src/api/client.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// Mock the Firebase module so tests control the "signed-in" user and its token +// without initializing a real Firebase app. `vi.hoisted` runs before the hoisted +// `vi.mock` factory, so the shared state is safe to reference inside it. +const { getIdToken, authState } = vi.hoisted(() => { + const getIdToken = vi.fn<(force?: boolean) => Promise>(); + return { + getIdToken, + authState: { currentUser: null } as { + currentUser: { getIdToken: typeof getIdToken } | null; + }, + }; +}); +vi.mock("../firebase", () => ({ auth: authState })); + +import { api, signupCompany, ApiError } from "./client"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + getIdToken.mockReset(); + authState.currentUser = null; +}); + +describe("ApiError", () => { + it("treats HTTP 401 as unauthenticated", () => { + expect(new ApiError(401, "SOMETHING", "nope").isUnauthenticated).toBe(true); + }); + + it("treats the UNAUTHENTICATED code as unauthenticated regardless of status", () => { + expect(new ApiError(403, "UNAUTHENTICATED", "nope").isUnauthenticated).toBe(true); + }); + + it("is not unauthenticated for ordinary errors", () => { + expect(new ApiError(500, "INTERNAL", "boom").isUnauthenticated).toBe(false); + }); +}); + +describe("api.get", () => { + it("returns the parsed envelope on success", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: [{ id: "e1" }] })); + const result = await api.get<{ id: string }[]>("/employees"); + expect(result).toEqual({ data: [{ id: "e1" }] }); + }); + + it("attaches a bearer token when a user is signed in", async () => { + authState.currentUser = { getIdToken }; + getIdToken.mockResolvedValue("tok-123"); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: {} })); + + await api.get("/me"); + + const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record; + expect(headers.Authorization).toBe("Bearer tok-123"); + }); + + it("omits the Authorization header when signed out", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: {} })); + await api.get("/public"); + const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record; + expect(headers.Authorization).toBeUndefined(); + }); + + it("serializes defined query params and drops null/undefined", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: [] })); + await api.get("/attendance", { from: "2026-07-01", to: undefined, page: 2, q: null }); + + const url = fetchMock.mock.calls[0][0] as URL; + expect(url.searchParams.get("from")).toBe("2026-07-01"); + expect(url.searchParams.get("page")).toBe("2"); + expect(url.searchParams.has("to")).toBe(false); + expect(url.searchParams.has("q")).toBe(false); + }); + + it("throws a typed ApiError carrying the RFC 7807 problem", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(422, { + code: "VALIDATION", + detail: "Invalid input", + fieldErrors: { email: "required" }, + }), + ); + + const error = await api.get("/employees").catch((e) => e); + expect(error).toBeInstanceOf(ApiError); + expect(error.status).toBe(422); + expect(error.code).toBe("VALIDATION"); + expect(error.message).toBe("Invalid input"); + expect(error.fieldErrors).toEqual({ email: "required" }); + }); + + it("falls back to status-based defaults for a non-JSON error body", async () => { + fetchMock.mockResolvedValueOnce( + new Response("oops", { status: 503, statusText: "Service Unavailable" }), + ); + const error = (await api.get("/x").catch((e) => e)) as ApiError; + expect(error.code).toBe("HTTP_503"); + expect(error.message).toBe("Service Unavailable"); + }); +}); + +describe("token refresh retry", () => { + it("retries once with a force-refreshed token after a 401", async () => { + authState.currentUser = { getIdToken }; + getIdToken.mockResolvedValueOnce("stale").mockResolvedValueOnce("fresh"); + fetchMock + .mockResolvedValueOnce(jsonResponse(401, { code: "UNAUTHENTICATED", detail: "expired" })) + .mockResolvedValueOnce(jsonResponse(200, { data: { ok: true } })); + + const result = await api.get<{ ok: boolean }>("/me"); + + expect(result).toEqual({ data: { ok: true } }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(getIdToken).toHaveBeenLastCalledWith(true); + const retryHeaders = (fetchMock.mock.calls[1][1] as RequestInit).headers as Record; + expect(retryHeaders.Authorization).toBe("Bearer fresh"); + }); + + it("does not retry when signed out", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(401, { code: "UNAUTHENTICATED", detail: "no" })); + await api.get("/me").catch(() => undefined); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("api.post", () => { + it("sends an idempotency key and JSON body by default", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: { id: "new" } })); + await api.post("/leave", { days: 2 }); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + const headers = init.headers as Record; + expect(init.method).toBe("POST"); + expect(headers["Content-Type"]).toBe("application/json"); + expect(headers["Idempotency-Key"]).toMatch(/.+/); + expect(init.body).toBe(JSON.stringify({ days: 2 })); + }); + + it("omits the idempotency key when disabled", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { data: {} })); + await api.post("/leave", { days: 2 }, false); + const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record; + expect(headers["Idempotency-Key"]).toBeUndefined(); + }); +}); + +describe("signupCompany (public endpoint)", () => { + it("unwraps the envelope data on success and sends no auth header", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { data: { companyId: "c1", employeeId: "e1" } }), + ); + + const result = await signupCompany({ + companyName: "Acme", + adminFirstName: "A", + adminLastName: "B", + email: "a@b.com", + password: "secret", + }); + + expect(result).toEqual({ companyId: "c1", employeeId: "e1" }); + const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record; + expect(headers.Authorization).toBeUndefined(); + }); + + it("throws an ApiError on a failed signup", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(409, { code: "EMAIL_TAKEN", detail: "Email already registered" }), + ); + const error = (await signupCompany({ + companyName: "Acme", + adminFirstName: "A", + adminLastName: "B", + email: "taken@b.com", + password: "secret", + }).catch((e) => e)) as ApiError; + + expect(error).toBeInstanceOf(ApiError); + expect(error.code).toBe("EMAIL_TAKEN"); + }); +}); diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..8b53ace --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,132 @@ +import { auth } from "../firebase"; +import type { Envelope, Problem } from "./types"; + +const BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/$/, ""); + +/** Typed API error carrying the RFC 7807 problem code and any field errors. */ +export class ApiError extends Error { + constructor( + readonly status: number, + readonly code: string, + detail: string, + readonly fieldErrors: Record = {}, + ) { + super(detail); + } + + get isUnauthenticated(): boolean { + return this.status === 401 || this.code === "UNAUTHENTICATED"; + } +} + +function ulid(): string { + // Idempotency key for POSTs; simplicity over sortability is fine client-side. + return ( + Date.now().toString(36) + Math.random().toString(36).slice(2, 12) + ).toUpperCase(); +} + +async function authHeader(forceRefresh = false): Promise> { + const token = await auth.currentUser?.getIdToken(forceRefresh); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +interface RequestOptions { + method?: string; + body?: unknown; + query?: Record; + idempotent?: boolean; +} + +async function request(path: string, options: RequestOptions = {}): Promise { + const { method = "GET", body, query, idempotent } = options; + + // Pass the page origin as the base so a relative BASE_URL (e.g. "/v1" behind + // Firebase Hosting in production) resolves; an absolute BASE_URL (the local + // emulator) ignores the base. Without it, new URL("/v1/me") throws. + const url = new URL(`${BASE_URL}${path}`, window.location.origin); + if (query) { + for (const [key, val] of Object.entries(query)) { + if (val !== undefined && val !== null) url.searchParams.set(key, String(val)); + } + } + + const headers: Record = { + Accept: "application/json", + ...(await authHeader()), + }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + if (idempotent) headers["Idempotency-Key"] = ulid(); + + let response = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + // One retry with a force-refreshed token to cover ID-token expiry. + if (response.status === 401 && auth.currentUser) { + response = await fetch(url, { + method, + headers: { ...headers, ...(await authHeader(true)) }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } + + if (!response.ok) { + throw await toApiError(response); + } + if (response.status === 204) return undefined as T; + return (await response.json()) as T; +} + +async function toApiError(response: Response): Promise { + let problem: Problem = {}; + try { + problem = (await response.json()) as Problem; + } catch { + // Non-JSON error body; fall through to status-based defaults. + } + return new ApiError( + response.status, + problem.code ?? `HTTP_${response.status}`, + problem.detail ?? problem.title ?? response.statusText, + problem.fieldErrors ?? {}, + ); +} + +export const api = { + get: (path: string, query?: RequestOptions["query"]) => + request>(path, { query }).then((e) => e), + post: (path: string, body: unknown, idempotent = true) => + request>(path, { method: "POST", body, idempotent }), + put: (path: string, body: unknown) => + request>(path, { method: "PUT", body }), + // PATCH for partial edits, where PUT would need the caller to resend fields + // it never touched — and, sending them back stale, quietly undo somebody + // else's change. + patch: (path: string, body: unknown) => + request>(path, { method: "PATCH", body }), + del: (path: string) => request>(path, { method: "DELETE" }), +}; + +/** Public (unauthenticated) endpoints — no bearer token attached. */ +export async function signupCompany(body: { + companyName: string; + /** Optional; an unrecognised value is ignored by the server, never refused. */ + businessType?: string; + adminFirstName: string; + adminLastName: string; + email: string; + password: string; +}): Promise<{ companyId: string; employeeId: string }> { + const response = await fetch(`${BASE_URL}/public/signup`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw await toApiError(response); + } + return ((await response.json()) as Envelope<{ companyId: string; employeeId: string }>).data; +} diff --git a/web/src/api/hooks.ts b/web/src/api/hooks.ts new file mode 100644 index 0000000..946a990 --- /dev/null +++ b/web/src/api/hooks.ts @@ -0,0 +1,947 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "./client"; +import { isoTodayIn } from "../time"; +import type { + Account, + Advance, + AdvanceWrite, + AppNotification, + EmployeeDocument, + PieceRecord, + AttendanceOverviewRow, + WeeklyAttendance, + CompanySettings, + CalendarDay, + CompanyDeletion, + ComponentAssignment, + SupportTicket, + DayKind, + Employee, + EmployeeSalary, + EmployeeSalaryWrite, + EmployeeCreated, + EmployeeWrite, + Expense, + ExpenseCategory, + FinanceOverview, + JournalEntry, + JournalLine, + Holiday, + HolidayWrite, + KioskAccount, + License, + LicensedDevice, + KioskAccountCreated, + Kpis, + LeaveRequest, + DayBoard, + MyWork, + Project, + ProjectWrite, + TaskStatus, + TaskWrite, + WorkTask, + WorkTeam, + WorkTeamWrite, + PayrollRun, + SalaryComponent, + SalaryComponentWrite, + PayrollRunResult, + Regularization, + RosterRow, + RunPayslipRow, + Shift, + ShiftWrite, + TrendPoint, + TrialBalance, +} from "./types"; + +export function useKpis(date?: string) { + return useQuery({ + queryKey: ["kpis", date ?? "today"], + queryFn: () => api.get("/analytics/kpis", { date }).then((e) => e.data), + }); +} + +export function useAttendanceTrend(date?: string) { + return useQuery({ + queryKey: ["attendance-trend", date ?? "today"], + queryFn: () => + api + .get<{ points: TrendPoint[] }>("/analytics/attendance-trend", { date }) + .then((e) => e.data.points), + }); +} + +export function useAttendanceOverview(date?: string, timeZone = "Asia/Kabul") { + // The live board must not go stale while a manager watches it: a check-in + // made now should appear without a manual reload. Past days never change, + // so they are fetched once instead of polled. "Today" is the company's day, + // which is not the viewer's when they are in another country. + return useQuery({ ...overviewQuery(date, timeZone), select: (d) => d.rows }); +} + +interface OverviewResponse { + date: string; + dayKind: DayKind; + holidayName: string | null; + rows: AttendanceOverviewRow[]; +} + +/** + * Shared so the rows and the day kind come from one request. React Query keys + * them the same, and each hook picks its slice with `select`. + */ +function overviewQuery(date: string | undefined, timeZone: string) { + const isLive = date === undefined || date === isoTodayIn(timeZone); + return { + queryKey: ["attendance-overview", date ?? "today"] as const, + queryFn: () => + api.get("/attendance/overview", { date }).then((e) => e.data), + refetchInterval: isLive ? (60_000 as const) : (false as const), + refetchIntervalInBackground: false, + }; +} + +/** + * Whether the board's day is worked at all. Without this a Friday or a public + * holiday renders as a full page of ABSENT with nothing explaining why. + */ +export function useAttendanceDay(date?: string, timeZone = "Asia/Kabul") { + return useQuery({ + ...overviewQuery(date, timeZone), + select: (d) => ({ date: d.date, kind: d.dayKind, holidayName: d.holidayName }), + }); +} + +/** One week of attendance for the whole team (manager's weekly review). */ +export function useWeeklyAttendance(date?: string, timeZone = "Asia/Kabul") { + const isLive = date === undefined || date === isoTodayIn(timeZone); + return useQuery({ + queryKey: ["attendance-weekly", date ?? "today"], + queryFn: () => + api.get("/attendance/weekly", { date }).then((e) => e.data), + refetchInterval: isLive ? 60_000 : false, + refetchIntervalInBackground: false, + }); +} + +export function useEmployees(params: { cursor?: string; branchId?: string; status?: string }) { + return useQuery({ + queryKey: ["employees", params], + queryFn: () => + api.get("/employees", { + cursor: params.cursor, + branchId: params.branchId, + status: params.status, + limit: 50, + }), + }); +} + +export function useCreateEmployee() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: EmployeeWrite) => + api.post("/employees", body).then((e) => e.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["employees"] }), + }); +} + +export function useUpdateEmployee() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (args: { id: string; body: EmployeeWrite }) => + api.put(`/employees/${args.id}`, args.body).then((e) => e.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["employees"] }), + }); +} + +export function useResetEmployeePassword() { + return useMutation({ + mutationFn: (args: { id: string; password?: string }) => + api + .post<{ tempPassword: string }>( + `/employees/${args.id}/reset-password`, + args.password ? { password: args.password } : {}, + ) + .then((e) => e.data), + }); +} + +/** Admin: clear an employee's face enrollment so they can re-enroll. */ +export function useResetEmployeeFace() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (employeeId: string) => + api.del<{ faceEnrolled: boolean }>(`/employees/${employeeId}/face`).then((e) => e.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["employees"] }), + }); +} + +export function usePayrollRuns() { + return useQuery({ + queryKey: ["payroll", "runs"], + queryFn: () => api.get("/payroll/runs").then((e) => e.data), + }); +} + +export function useRunPayslips(runId: string | null) { + return useQuery({ + enabled: runId !== null, + queryKey: ["payroll", "run", runId], + queryFn: () => + api + .get<{ runId: string; payslips: RunPayslipRow[] }>(`/payroll/runs/${runId}/payslips`) + .then((e) => e.data.payslips), + }); +} + +export function useRunPayroll() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (args: { periodYear: number; periodMonth: number }) => + api.post("/payroll/runs", args).then((e) => e.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["payroll"] }), + }); +} + +// -------------------------------------------------------------------- advances + +export function useAdvances(employeeId: string | null) { + return useQuery({ + queryKey: ["advances", employeeId ?? "all"], + queryFn: () => + api + .get(`/advances${employeeId ? `?employeeId=${encodeURIComponent(employeeId)}` : ""}`) + .then((e) => e.data), + }); +} + +export function useCreateAdvance() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: AdvanceWrite) => api.post("/advances", body).then((e) => e.data), + // Payroll reads outstanding advances, so a new one changes what the next + // run will deduct. + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["advances"] }); + void qc.invalidateQueries({ queryKey: ["payroll"] }); + }, + }); +} + +export function useCancelAdvance() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.post<{ id: string }>(`/advances/${id}/cancel`, {}).then((e) => e.data), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["advances"] }); + void qc.invalidateQueries({ queryKey: ["payroll"] }); + }, + }); +} + +// ------------------------------------------------------------------ documents + +export function useEmployeeDocuments(employeeId: string | null) { + return useQuery({ + enabled: employeeId !== null, + queryKey: ["documents", employeeId], + queryFn: () => + api + .get(`/documents?employeeId=${encodeURIComponent(employeeId ?? "")}`) + .then((e) => e.data), + }); +} + +export function useExpiringDocuments() { + return useQuery({ + queryKey: ["documents", "expiring"], + queryFn: () => api.get("/documents/expiring").then((e) => e.data), + }); +} + +export function useAddDocument() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: Record) => + api.post("/documents", body).then((e) => e.data), + onSuccess: () => void qc.invalidateQueries({ queryKey: ["documents"] }), + }); +} + +export function useDeleteDocument() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.del<{ id: string }>(`/documents/${id}`).then((e) => e.data), + onSuccess: () => void qc.invalidateQueries({ queryKey: ["documents"] }), + }); +} + +// -------------------------------------------------------------- notifications + +export function useNotifications() { + return useQuery({ + queryKey: ["notifications"], + queryFn: () => + api + .get<{ items: AppNotification[]; unread: number }>("/notifications") + .then((e) => e.data), + // A decision made elsewhere should reach the screen without a reload. Sixty + // seconds is often enough for something nobody is staring at, and cheap. + refetchInterval: 60_000, + }); +} + +export function useMarkNotificationRead() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.post(`/notifications/${id}/read`, {}), + onSuccess: () => void qc.invalidateQueries({ queryKey: ["notifications"] }), + }); +} + +export function useMarkAllNotificationsRead() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.post("/notifications/read-all", {}), + onSuccess: () => void qc.invalidateQueries({ queryKey: ["notifications"] }), + }); +} + +// ------------------------------------------------------------------ piecework + +export function usePieceRecords() { + return useQuery({ + queryKey: ["pieceWork"], + queryFn: () => api.get("/piece-work").then((e) => e.data), + }); +} + +export function useRecordPieces() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: { employeeId: string; date: string; quantity: number; note?: string | null }) => + api.post("/piece-work", body).then((e) => e.data), + // A piece count IS the wage for anybody on that model, so payroll's view + // of the month changes with it. + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["pieceWork"] }); + void qc.invalidateQueries({ queryKey: ["payroll"] }); + }, + }); +} + +export function useDeletePieceRecord() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.del<{ id: string }>(`/piece-work/${id}`).then((e) => e.data), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["pieceWork"] }); + void qc.invalidateQueries({ queryKey: ["payroll"] }); + }, + }); +} + +// --------------------------------------------------------------------- finance + +export function useFinanceOverview(enabled = true) { + return useQuery({ + enabled, + queryKey: ["finance", "overview"], + queryFn: () => api.get("/finance/overview").then((e) => e.data), + }); +} + +export function useExpenses(status?: string) { + return useQuery({ + queryKey: ["finance", "expenses", status ?? "all"], + queryFn: () => + api.get("/finance/expenses", { status }).then((e) => e.data), + }); +} + +// --------------------------------------------------------------- salary setup + +/** + * An employee's salary. Payroll skips anyone without one, so this is what + * stands between a company and its first payslip. + */ +export function useEmployeeSalary(employeeId: string | null) { + return useQuery({ + queryKey: ["employee-salary", employeeId], + enabled: Boolean(employeeId), + queryFn: () => + api.get(`/payroll/employees/${employeeId}/salary`).then((e) => e.data), + }); +} + +export function useSetEmployeeSalary() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: EmployeeSalaryWrite }) => + api.put(`/payroll/employees/${id}/salary`, body).then((e) => e.data), + onSuccess: (_d, v) => { + void qc.invalidateQueries({ queryKey: ["employee-salary", v.id] }); + }, + }); +} + +export function useSalaryComponents() { + return useQuery({ + queryKey: ["salary-components"], + queryFn: () => api.get("/payroll/components").then((e) => e.data), + }); +} + +export function useSaveSalaryComponent() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id?: string; body: SalaryComponentWrite }) => + id + ? api.put(`/payroll/components/${id}`, body).then((e) => e.data) + : api.post("/payroll/components", body).then((e) => e.data), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["salary-components"] }); + }, + }); +} + +/** Issues this company has raised with Linumic. */ +export function useSupportTickets() { + return useQuery({ + queryKey: ["support-tickets"], + queryFn: () => api.get("/support/tickets").then((e) => e.data), + }); +} + +export function useRaiseSupportTicket() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: { subject: string; detail?: string }) => + api.post<{ id: string }>("/support/tickets", body, false).then((e) => e.data), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["support-tickets"] }); + }, + }); +} + +/** Which components one employee gets, and at what amount. */ +export function useEmployeeComponents(employeeId: string | null) { + return useQuery({ + enabled: Boolean(employeeId), + queryKey: ["employee-components", employeeId], + queryFn: () => + api + .get(`/payroll/employees/${employeeId}/components`) + .then((e) => e.data), + }); +} + +export function useSetEmployeeComponent() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + employeeId, + componentId, + body, + }: { + employeeId: string; + componentId: string; + body: { value: number | null; active: boolean }; + }) => + api + .put( + `/payroll/employees/${employeeId}/components/${componentId}`, + body, + ) + .then((e) => e.data), + onSuccess: (_d, v) => { + void qc.invalidateQueries({ queryKey: ["employee-components", v.employeeId] }); + }, + }); +} + +/** Returns the employee to whatever the component itself does. */ +export function useClearEmployeeComponent() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ employeeId, componentId }: { employeeId: string; componentId: string }) => + api.del(`/payroll/employees/${employeeId}/components/${componentId}`), + onSuccess: (_d, v) => { + void qc.invalidateQueries({ queryKey: ["employee-components", v.employeeId] }); + }, + }); +} + +export function useCreateExpense() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: { + category: ExpenseCategory; + vendor: string; + description: string; + amount: number; + date: string; + }) => api.post("/finance/expenses", body).then((e) => e.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["finance"] }), + }); +} + +export function useDecideExpense() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (args: { id: string; action: "APPROVE" | "REJECT" | "PAY" }) => + api + .post(`/finance/expenses/${args.id}/decide`, { action: args.action }) + .then((e) => e.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["finance"] }), + }); +} + +export function useAccounts(enabled = true) { + return useQuery({ + enabled, + queryKey: ["finance", "accounts"], + queryFn: () => api.get("/finance/accounts").then((e) => e.data), + }); +} + +export function useJournal(enabled = true) { + return useQuery({ + enabled, + queryKey: ["finance", "journal"], + queryFn: () => api.get("/finance/journal").then((e) => e.data), + }); +} + +export function useCreateJournalEntry() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: { date: string; memo: string; lines: JournalLine[] }) => + api.post<{ id: string }>("/finance/journal", body).then((e) => e.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["finance"] }), + }); +} + +export function useTrialBalance(enabled = true) { + return useQuery({ + enabled, + queryKey: ["finance", "trial-balance"], + queryFn: () => api.get("/finance/trial-balance").then((e) => e.data), + }); +} + +export function usePendingApprovals() { + return useQuery({ + queryKey: ["leave", "approvals"], + queryFn: () => + api.get("/leave/requests", { scope: "approvals" }).then((e) => e.data), + }); +} + +export function useDecideLeave() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (args: { id: string; decision: "APPROVE" | "REJECT"; note?: string | null }) => + api + .post(`/leave/requests/${args.id}/decide`, { + decision: args.decision, + note: args.note ?? null, + }) + .then((e) => e.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["leave", "approvals"] }), + }); +} + +// -------------------------------------------------------------- shifts & roster + +export function useShifts() { + return useQuery({ + queryKey: ["shifts"], + queryFn: () => api.get("/shifts").then((e) => e.data), + }); +} + +export function useSaveShift() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (args: { id?: string; body: ShiftWrite }) => + (args.id + ? api.put(`/shifts/${args.id}`, args.body) + : api.post("/shifts", args.body) + ).then((e) => e.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["shifts"] }), + }); +} + +export function useRoster(date: string) { + return useQuery({ + queryKey: ["roster", date], + queryFn: () => + api + .get<{ date: string; rows: RosterRow[] }>("/shifts/roster", { date }) + .then((e) => e.data.rows), + }); +} + +export function useAssignRoster() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: { + employeeIds: string[]; + shiftId: string; + from: string; + to?: string; + branchId?: string | null; + }) => api.post<{ created: number }>("/shifts/roster/assign", body).then((e) => e.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["roster"] }), + }); +} + +// ----------------------------------------------------------------------- kiosk + +/** Current rotating kiosk token; refetched well before the 30s slot expires. */ +export function useKioskToken(kioskId?: string) { + return useQuery({ + queryKey: ["kiosk-token", kioskId ?? "default"], + queryFn: () => + api + .get<{ token: string; kioskId: string; companyName: string; rotateSeconds: number }>( + "/kiosk/token", + { kioskId }, + ) + .then((e) => e.data), + refetchInterval: 20_000, + refetchIntervalInBackground: true, + staleTime: 0, + }); +} + +export function useKioskAccounts(enabled: boolean) { + return useQuery({ + enabled, + queryKey: ["kiosk-accounts"], + queryFn: () => api.get("/kiosk/accounts").then((e) => e.data), + }); +} + +export function useCreateKioskAccount() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: { label: string; branchId?: string | null }) => + api.post("/kiosk/accounts", body).then((e) => e.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["kiosk-accounts"] }), + }); +} + +export function useResetKioskAccount() { + return useMutation({ + mutationFn: (kioskId: string) => + api + .post<{ kioskId: string; password: string }>(`/kiosk/accounts/${kioskId}/reset`, {}) + .then((e) => e.data), + }); +} + +// -------------------------------------------------------------------- settings + +export function useSettings() { + return useQuery({ + queryKey: ["settings"], + queryFn: () => api.get("/settings").then((e) => e.data), + }); +} + +export function useUpdateSettings() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (patch: Partial) => + api.put("/settings", patch).then((e) => e.data), + onSuccess: (data) => { + qc.setQueryData(["settings"], data); + void qc.invalidateQueries({ queryKey: ["me"] }); + }, + }); +} + +export function usePendingRegularizations(enabled: boolean) { + return useQuery({ + enabled, + queryKey: ["regularizations", "approvals"], + queryFn: () => + api + .get("/attendance/regularizations", { scope: "approvals" }) + .then((e) => e.data), + }); +} + +export function useDecideRegularization() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (args: { id: string; decision: "APPROVE" | "REJECT"; note?: string | null }) => + api + .post(`/attendance/regularizations/${args.id}/decide`, { + decision: args.decision, + note: args.note ?? null, + }) + .then((e) => e.data), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["regularizations", "approvals"] }); + void qc.invalidateQueries({ queryKey: ["attendance-overview"] }); + }, + }); +} + +// ------------------------------------------------------------ device licence + +export function useLicense(enabled: boolean) { + return useQuery({ + enabled, + queryKey: ["license"], + queryFn: () => api.get("/devices/license").then((e) => e.data), + }); +} + +// There is no useSaveLicence: the licence is what the customer buys, so the +// server has no endpoint to write it. The vendor issues licences with +// backend/functions/src/scripts/set-license.ts. + +export function useDevices(enabled: boolean) { + return useQuery({ + enabled, + queryKey: ["devices"], + queryFn: () => api.get("/devices").then((e) => e.data), + }); +} + +export function useSetDeviceStatus() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ deviceId, action }: { deviceId: string; action: "revoke" | "restore" }) => + api.post(`/devices/${deviceId}/${action}`, {}).then((e) => e.data), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["devices"] }); + }, + }); +} + +// ------------------------------------------------------------ working calendar + +export function useHolidays(from?: string, to?: string) { + const qs = from && to ? `?from=${from}&to=${to}` : ""; + return useQuery({ + queryKey: ["holidays", from ?? null, to ?? null], + queryFn: () => api.get(`/calendar/holidays${qs}`).then((e) => e.data), + }); +} + +/** Every date in a range with whether it is worked, a weekend, or a holiday. */ +export function useCalendarDays(from: string, to: string, enabled = true) { + return useQuery({ + enabled, + queryKey: ["calendar-days", from, to], + queryFn: () => + api.get(`/calendar/days?from=${from}&to=${to}`).then((e) => e.data), + }); +} + +export function useSaveHoliday() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ date, ...body }: HolidayWrite & { date: string }) => + api.put(`/calendar/holidays/${date}`, body).then((e) => e.data), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["holidays"] }); + void qc.invalidateQueries({ queryKey: ["calendar-days"] }); + }, + }); +} + +export function useDeleteHoliday() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (date: string) => api.del(`/calendar/holidays/${date}`), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["holidays"] }); + void qc.invalidateQueries({ queryKey: ["calendar-days"] }); + }, + }); +} + +/** Generates the fixed Solar Hijri holidays for a year. */ +export function useSeedHolidays() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (year: number) => + api.post<{ year: number; added: number }>("/calendar/holidays/seed", { year }).then((e) => e.data), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["holidays"] }); + void qc.invalidateQueries({ queryKey: ["calendar-days"] }); + }, + }); +} + +// --------------------------------------------------------- closing the account + +export function useCompanyDeletion(enabled: boolean) { + return useQuery({ + enabled, + queryKey: ["company-deletion"], + queryFn: () => api.get("/company/deletion").then((e) => e.data), + }); +} + +export function useRequestCompanyDeletion() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: { confirmName: string; reason?: string | null }) => + api.post("/company/deletion", body).then((e) => e.data), + onSuccess: () => void qc.invalidateQueries({ queryKey: ["company-deletion"] }), + }); +} + +export function useCancelCompanyDeletion() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.del("/company/deletion"), + onSuccess: () => void qc.invalidateQueries({ queryKey: ["company-deletion"] }), + }); +} + +// ---------------------------------------------------------------- work + +/** Everything the work board reads invalidates together: one plan, one cache. */ +function invalidateWork(qc: ReturnType) { + void qc.invalidateQueries({ queryKey: ["work-tasks"] }); + void qc.invalidateQueries({ queryKey: ["work-board"] }); + void qc.invalidateQueries({ queryKey: ["my-work"] }); +} + +export function useProjects() { + return useQuery({ + queryKey: ["projects"], + queryFn: () => api.get("/work/projects").then((e) => e.data), + }); +} + +export function useSaveProject() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id?: string; body: ProjectWrite }) => + id + ? api.put(`/work/projects/${id}`, body).then((e) => e.data) + : api.post("/work/projects", body).then((e) => e.data), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["projects"] }); + invalidateWork(qc); + }, + }); +} + +export function useDeleteProject() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.del(`/work/projects/${id}`), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["projects"] }); + }, + }); +} + +export function useWorkTeams() { + return useQuery({ + queryKey: ["work-teams"], + queryFn: () => api.get("/work/teams").then((e) => e.data), + }); +} + +export function useSaveWorkTeam() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id?: string; body: WorkTeamWrite }) => + id + ? api.put(`/work/teams/${id}`, body).then((e) => e.data) + : api.post("/work/teams", body).then((e) => e.data), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["work-teams"] }); + }, + }); +} + +export function useDeleteWorkTeam() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.del(`/work/teams/${id}`), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["work-teams"] }); + }, + }); +} + +/** Tasks overlapping a date window, optionally narrowed to a project or person. */ +export function useWorkTasks( + from: string, + to: string, + filter: { projectId?: string; employeeId?: string } = {}, +) { + return useQuery({ + queryKey: ["work-tasks", from, to, filter.projectId ?? "", filter.employeeId ?? ""], + queryFn: () => + api + .get("/work/tasks", { + from, + to, + projectId: filter.projectId, + employeeId: filter.employeeId, + }) + .then((e) => e.data), + }); +} + +/** One day, grouped by person — the morning question, answered. */ +export function useDayBoard(date: string) { + return useQuery({ + queryKey: ["work-board", date], + queryFn: () => api.get("/work/board", { date }).then((e) => e.data), + }); +} + +/** The signed-in manager's own assignments — the same view their staff get. */ +export function useMyWork() { + return useQuery({ + queryKey: ["my-work"], + queryFn: () => api.get("/work/mine").then((e) => e.data), + }); +} + +export function useSaveTask() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id?: string; body: TaskWrite | Partial }) => + id + ? api.patch(`/work/tasks/${id}`, body).then((e) => e.data) + : api.post("/work/tasks", body).then((e) => e.data), + onSuccess: () => invalidateWork(qc), + }); +} + +export function useDeleteTask() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.del(`/work/tasks/${id}`), + onSuccess: () => invalidateWork(qc), + }); +} + +export function useSetTaskStatus() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, status, note }: { id: string; status: TaskStatus; note?: string }) => + api + .post(`/work/tasks/${id}/status`, { status, note }, false) + .then((e) => e.data), + onSuccess: () => invalidateWork(qc), + }); +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..2feef8d --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,724 @@ +// Wire types mirroring the backend REST API v1 responses. + +export interface Envelope { + data: T; + meta?: { cursor?: string | null; hasMore?: boolean }; +} + +export interface Problem { + type?: string; + title?: string; + status?: number; + code?: string; + detail?: string; + fieldErrors?: Record; +} + +export interface CompanyFeatures { + shifts: boolean; + leave: boolean; + payroll: boolean; + regularization: boolean; + announcements: boolean; + geofencing: boolean; + qrKiosk: boolean; + faceRecognition: boolean; + finance: boolean; +} + +export interface CompanyPolicies { + standardDailyMinutes: number; + /** ISO weekday numbers (Mon=1 … Sun=7). Afghanistan defaults to Friday (5). */ + weekendDays: number[]; + lateGraceMinutes: number; + overtimeEnabled: boolean; +} + +export interface CompanyProfile { + currency: string; + timezone: string; +} + +export interface CompanySettings { + features: CompanyFeatures; + policies: CompanyPolicies; + profile: CompanyProfile; +} + +export interface Me { + uid: string; + companyId: string; + companyName: string; + currency: string; + /** IANA zone the company operates in; attendance dates are resolved in it. */ + timezone: string; + employeeId: string; + displayName: string; + email: string; + avatarUrl: string | null; + roles: string[]; + branchIds: string[]; + features: CompanyFeatures; +} + +export interface Shift { + id: string; + companyId: string; + name: string; + code: string; + startTime: string; // "HH:mm" + endTime: string; + breakMinutes: number; + graceInMinutes: number; + graceOutMinutes: number; + isNightShift: boolean; + active: boolean; + updatedAt: string; +} + +export interface ShiftWrite { + name: string; + code: string; + startTime: string; + endTime: string; + breakMinutes: number; + graceInMinutes: number; + graceOutMinutes: number; + active: boolean; +} + +export interface KioskAccount { + kioskId: string; + label: string; + email: string | null; + branchId: string | null; + active: boolean; + createdAt: string | null; +} + +/** POST /kiosk/accounts echoes the one-time device credentials. */ +export interface KioskAccountCreated { + kioskId: string; + label: string; + email: string; + password: string; + branchId: string | null; +} + +export interface RosterRow { + id: string; + employeeId: string; + employeeName: string; + shiftId: string; + shiftName: string; + branchId: string | null; + date: string; +} + +export type EmploymentType = "FULL_TIME" | "PART_TIME" | "CONTRACT" | "INTERN"; +export type EmployeeStatus = "ACTIVE" | "ON_LEAVE" | "SUSPENDED" | "EXITED"; + +export interface Employee { + id: string; + companyId: string; + employeeCode: string; + /** Null for employees created before roles were shown; means "unknown", not EMPLOYEE. */ + role?: string | null; + firstName: string; + lastName: string; + email: string; + phone: string | null; + avatarUrl: string | null; + branchId: string | null; + departmentId: string | null; + positionId: string | null; + managerId: string | null; + employmentType: EmploymentType; + joinDate: string; + status: EmployeeStatus; + faceEnrolled: boolean; + updatedAt: string; +} + +export type AssignableRole = + | "EMPLOYEE" + | "TEAM_LEAD" + | "BRANCH_MANAGER" + | "HR_ADMIN" + | "PAYROLL_ADMIN" + | "AUDITOR"; + +export interface EmployeeWrite { + /** + * Left out to have the server assign the next code. On an edit, leaving it + * out means "keep the one this employee already has" — the update writes the + * whole document, so sending an empty string would erase it. + */ + employeeCode?: string; + firstName: string; + lastName: string; + email: string; + phone?: string | null; + branchId?: string | null; + departmentId?: string | null; + positionId?: string | null; + managerId?: string | null; + employmentType: EmploymentType; + joinDate: string; + status: EmployeeStatus; + role?: AssignableRole; + createLogin?: boolean; + initialPassword?: string; +} + +/** POST /employees echoes the created employee plus the temp login password. */ +export interface EmployeeCreated extends Employee { + tempPassword: string | null; +} + +export interface Branch { + id: string; + companyId: string; + name: string; + code: string; + timezone: string; + updatedAt: string; +} + +export interface Kpis { + date: string; + activeEmployees: number; + present: number; + halfDay: number; + late: number; + onLeave: number; + absent: number; + pendingLeaveRequests: number; + attendanceRate: number; +} + +export interface TrendPoint { + date: string; + present: number; +} + +export interface AttendanceOverviewRow { + employeeId: string; + employeeName: string; + branchId: string | null; + status: string; + firstInAt: string | null; + lastOutAt: string | null; + workedMinutes: number; + lateMinutes: number; + /** + * Whether a check-in photo exists. The image itself is fetched on demand — + * inlining it here made a 500-row board polled every minute unusable. + */ + hasCheckInSelfie: boolean; + /** The check-in punch was confirmed against the employee's enrolled face. */ + checkInFaceVerified: boolean; + /** Face recognition is on, but a punch this day was not face-verified. */ + needsReview: boolean; + /** Employee record status; non-ACTIVE people appear only if they have a day. */ + employeeStatus: string; + /** Punches the server refused (geofence, clock skew, …) and did not count. */ + rejectedCount: number; + /** Machine-readable reason of the first refusal, e.g. GEOFENCE_VIOLATION. */ + rejectedReason: string | null; + rejectedAt: string | null; +} + +/** One employee's week on the manager's weekly report. */ +export interface WeeklyAttendanceRow { + employeeId: string; + employeeName: string; + employeeStatus: string; + branchId: string | null; + days: { + date: string; + status: string; + workedMinutes: number; + lateMinutes: number; + needsReview: boolean; + rejectedCount: number; + }[]; + totalWorkedMinutes: number; + presentDays: number; + lateDays: number; + needsReviewDays: number; +} + +export interface WeeklyAttendance { + from: string; + to: string; + /** The seven dates of the week, Saturday first. */ + dates: string[]; + rows: WeeklyAttendanceRow[]; +} + +/** An employee's basic pay. Null until someone configures it. */ +/** Monthly salary, a day's wage, or the price of one piece — see payModel. */ +export type PayModel = "MONTHLY" | "DAILY" | "PIECE"; + +export interface EmployeeSalary { + employeeId: string; + basicAmount: number; + payModel: PayModel; + currency: string; + effectiveFrom: string | null; + revisionReason: string | null; + updatedAt: string | null; +} + +export interface EmployeeSalaryWrite { + basicAmount: number; + /** Omitted on an edit keeps whatever is on file. */ + payModel?: PayModel; + effectiveFrom: string; + revisionReason?: string | null; +} + +/** An allowance, deduction or employer cost applied to every payslip. */ +export interface SalaryComponent { + id: string; + name: string; + code: string; + type: "EARNING" | "DEDUCTION" | "EMPLOYER_COST"; + calc: "FIXED" | "PERCENT_OF_BASIC" | "PERCENT_OF_GROSS"; + value: number; + taxable: boolean; + /** ALL applies to everyone unless withheld; INDIVIDUAL only where assigned. */ + scope: "ALL" | "INDIVIDUAL"; + active: boolean; +} + +export type SalaryComponentWrite = Omit; + +/** An issue this company raised with Linumic. A receipt, not the vendor's file. */ +export interface SupportTicket { + id: string; + subject: string; + status: "OPEN" | "WAITING" | "RESOLVED"; + openedAt: string | null; + resolvedAt: string | null; +} + +/** One employee's exception against a component: a different amount, or none. */ +export interface ComponentAssignment { + employeeId: string; + componentId: string; + /** Null means "the component's own amount". */ + value: number | null; + /** False withholds an otherwise company-wide component from this employee. */ + active: boolean; +} + +/** A company's device licence: how many devices may run the app at once. */ +export interface License { + plan: "FREE" | "STANDARD" | "ENTERPRISE"; + deviceLimit: number; + status: "ACTIVE" | "SUSPENDED" | "EXPIRED"; + expiresAt: string | null; + /** When false, devices are tracked but never refused — the rollout switch. */ + enforceDevices: boolean; +} + +/** One phone or kiosk occupying a licence seat. */ +export interface LicensedDevice { + deviceId: string; + type: string; + label: string | null; + platform: string | null; + model: string | null; + appVersion: string | null; + employeeId: string | null; + branchId: string | null; + status: "ACTIVE" | "REVOKED"; + activatedAt: string | null; + lastSeenAt: string | null; +} + +/** A day the company is closed. Keyed by the Gregorian date it is observed. */ +export interface Holiday { + date: string; + name: string; + nameEn: string; + paid: boolean; + /** SOLAR_RECURRING entries are generated per year; MANUAL ones were entered. */ + source: "SOLAR_RECURRING" | "MANUAL"; +} + +export interface HolidayWrite { + name: string; + nameEn?: string | null; + paid: boolean; +} + +export type DayKind = "WORKING" | "WEEKEND" | "HOLIDAY"; + +/** One date and what kind of day it is, for the attendance board. */ +export interface CalendarDay { + date: string; + kind: DayKind; + holidayName: string | null; + holidayNameEn: string | null; + paid: boolean | null; +} + +/** Where the company account stands: running, or scheduled to be closed. */ +export interface CompanyDeletion { + status: "NONE" | "SCHEDULED"; + requestedAt: string | null; + requestedBy: string | null; + /** Date from which the data is destroyed, YYYY-MM-DD. */ + purgeAfter: string | null; + reason: string | null; + graceDays: number; +} + +export interface PayrollRun { + id: string; + periodYear: number; + periodMonth: number; + status: string; + currency: string; + payslipCount: number; + totalGross: number; + totalNet: number; + totalTax: number; + totalEmployerCost: number; + /** Absent on runs made before this field existed; those were all whole months. */ + periodComplete?: boolean; + lockedAt: string | null; + createdAt: string | null; +} + +export interface PayrollRunResult { + runId: string; + periodYear: number; + periodMonth: number; + currency: string; + payslipCount: number; + totalNet: number; + totalGross: number; + totalTax: number; + totalEmployerCost: number; + periodComplete?: boolean; + /** Active employees left out of the run because they have no salary on file. */ + skippedNoSalary?: Array<{ employeeId: string; name: string }>; + /** People marked as having left who nonetheless worked in this period. */ + skippedExited?: Array<{ employeeId: string; name: string }>; +} + +export interface RunPayslipRow { + id: string; + employeeId: string; + /** Empty for employees who predate employee codes; the sheet prints a dash. */ + employeeCode: string; + employeeName: string; + currency: string; + gross: number; + totalDeductions: number; + net: number; + incomeTax: number; + employerCost: number; + costToCompany: number; + workedDays: number; + lopDays: number; + status: string; +} + +// ------------------------------------------------------------------- finance + +export type ExpenseStatus = "DRAFT" | "APPROVED" | "REJECTED" | "PAID"; +export type ExpenseCategory = + | "rent" + | "utilities" + | "supplies" + | "travel" + | "services" + | "other"; + +export interface Expense { + id: string; + category: ExpenseCategory; + vendor: string; + description: string; + amount: number; + currency: string; + date: string; + status: ExpenseStatus; + accountCode: string; + createdBy: string; + createdAt: string | null; + decidedBy: string | null; + decidedAt: string | null; +} + +export type AccountType = "ASSET" | "LIABILITY" | "EQUITY" | "INCOME" | "EXPENSE"; + +export interface Account { + id: string; + code: string; + name: string; + type: AccountType; + active: boolean; +} + +export interface JournalLine { + accountCode: string; + accountName: string; + debit: number; + credit: number; +} + +export interface JournalEntry { + id: string; + date: string; + memo: string; + reference: string | null; + source: "MANUAL" | "EXPENSE" | "PAYROLL"; + lines: JournalLine[]; + totalDebit: number; + createdBy: string; + createdAt: string | null; +} + +export interface TrialBalanceRow { + code: string; + name: string; + type: AccountType; + debit: number; + credit: number; + balance: number; +} + +export interface TrialBalance { + rows: TrialBalanceRow[]; + totalDebit: number; + totalCredit: number; + byType: Record; + netProfit: number; +} + +export interface FinanceOverview { + currency: string; + ledger: { + incomeTotal: number; + expenseTotal: number; + assetTotal: number; + liabilityTotal: number; + netProfit: number; + }; + expenses: { count: number; pendingCount: number; approvedTotal: number }; + payroll: { runCount: number; netTotal: number }; + trend: { month: string; income: number; expense: number; net: number }[]; +} + +export interface LeaveRequest { + id: string; + companyId: string; + employeeId: string; + employeeName: string | null; + leaveTypeId: string; + startDate: string; + endDate: string; + startHalfDay: boolean; + endHalfDay: boolean; + days: number; + reason: string; + status: string; + currentApproverId: string | null; + decidedAt: string | null; + decisionNote: string | null; + createdAt: string; + updatedAt: string; +} + +/** Employee-filed request to correct a day's check-in/check-out times. */ +export interface Regularization { + id: string; + companyId: string; + employeeId: string; + employeeName: string | null; + date: string; + requestedInAt: string | null; + requestedOutAt: string | null; + reason: string; + status: "PENDING" | "APPROVED" | "REJECTED" | "CANCELLED"; + currentApproverId: string | null; + decidedAt: string | null; + decisionNote: string | null; + createdAt: string; + updatedAt: string; +} + +// --------------------------------------------------------------- work + +/** A thing the company is building: a contract, a site, a phase. */ +export interface Project { + id: string; + companyId: string; + name: string; + code: string; + description: string | null; + branchId: string | null; + managerId: string | null; + status: "PLANNED" | "ACTIVE" | "PAUSED" | "DONE"; + startDate: string | null; + endDate: string | null; + updatedAt: string; +} + +export type ProjectWrite = Omit; + +/** + * A named crew. Not a department: the plastering team is drawn from three + * departments and looks different next month. + */ +export interface WorkTeam { + id: string; + companyId: string; + name: string; + projectId: string | null; + leadId: string | null; + memberIds: string[]; + active: boolean; + updatedAt: string; +} + +export type WorkTeamWrite = Omit; + +export type TaskStatus = "PLANNED" | "IN_PROGRESS" | "DONE" | "BLOCKED"; + +/** + * One piece of work, on a date range, for one or more people. + * + * `assigneeIds` is always people — assigning a crew expands to its members on + * the server, so `teamId` records where the assignment came from rather than + * who is responsible now. + */ +export interface WorkTask { + id: string; + companyId: string; + projectId: string; + projectName: string; + title: string; + detail: string | null; + location: string | null; + startDate: string; + endDate: string; + status: TaskStatus; + priority: "LOW" | "NORMAL" | "HIGH"; + teamId: string | null; + teamName: string | null; + assigneeIds: string[]; + assigneeNames: string[]; + statusNote: string | null; + completedAt: string | null; + updatedAt: string; +} + +export interface TaskWrite { + projectId: string; + title: string; + detail?: string | null; + location?: string | null; + startDate: string; + endDate?: string | null; + priority: "LOW" | "NORMAL" | "HIGH"; + teamId?: string | null; + assigneeIds: string[]; +} + +/** One day of one person's work, with why it is empty when it is. */ +export interface WorkDay { + date: string; + kind: DayKind; + tasks: WorkTask[]; +} + +export interface MyWork { + today: WorkDay; + next: WorkDay | null; +} + +export interface DayBoardRow { + employeeId: string; + name: string; + tasks: WorkTask[]; +} + +export interface DayBoard { + date: string; + rows: DayBoardRow[]; +} + +/** Money handed to somebody before payday, and what is left of it. */ +export interface Advance { + id: string; + employeeId: string; + employeeName: string; + principal: number; + /** Null takes the whole thing at the next payroll. */ + instalment: number | null; + issuedOn: string; + note: string | null; + repaid: number; + outstanding: number; + status: "OUTSTANDING" | "SETTLED" | "CANCELLED"; +} + +export interface AdvanceWrite { + employeeId: string; + principal: number; + instalment?: number | null; + issuedOn: string; + note?: string | null; +} + +/** One entry in a workshop's piece book: what somebody finished, and when. */ +export interface PieceRecord { + id: string; + employeeId: string; + employeeName: string; + date: string; + quantity: number; + note: string | null; +} + +/** Something the signed-in person needs to be told. */ +export interface AppNotification { + id: string; + kind: "LEAVE_DECIDED" | "CORRECTION_DECIDED" | "PAYSLIP_READY" | "APPROVAL_WAITING"; + title: string; + body: string; + link: string | null; + read: boolean; + createdAt: string | null; +} + +export type DocumentType = + | "TAZKIRA" + | "CONTRACT" + | "WORK_PERMIT" + | "HEALTH_CERTIFICATE" + | "LICENCE" + | "OTHER"; + +/** One paper the company holds for somebody, and when it runs out. */ +export interface EmployeeDocument { + id: string; + employeeId: string; + employeeName: string; + type: DocumentType; + number: string | null; + issuedOn: string | null; + /** Null for a document that does not expire, such as a tazkira. */ + expiresOn: string | null; + note: string | null; + /** Only on the expiring list. */ + standing?: "VALID" | "EXPIRING" | "EXPIRED" | "NO_EXPIRY"; + daysLeft?: number | null; +} diff --git a/web/src/auth/AuthProvider.tsx b/web/src/auth/AuthProvider.tsx new file mode 100644 index 0000000..6eef1cd --- /dev/null +++ b/web/src/auth/AuthProvider.tsx @@ -0,0 +1,217 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { + signInWithEmailAndPassword, + signOut as firebaseSignOut, + onAuthStateChanged, +} from "firebase/auth"; +import { auth } from "../firebase"; +import { api, ApiError } from "../api/client"; +import type { CompanyFeatures, Me } from "../api/types"; + +type Status = "loading" | "signedOut" | "signedIn" | "kiosk" | "vendor"; + +interface AuthContextValue { + status: Status; + me: Me | null; + signIn: (email: string, password: string) => Promise; + signOut: () => Promise; + /** Re-fetch /me so feature flags / roles reflect server-side changes (e.g. + * after saving company settings) without a full page reload. */ + refreshMe: () => Promise; +} + +const AuthContext = createContext(null); + +/** + * Roles allowed into the portal. + * + * EMPLOYEE is here so somebody whose phone cannot run the app — an iPhone, or + * no smartphone at all — can still see the work assigned to them. They get one + * page: their own. Every other route is hidden by permission, and the server + * refuses them regardless, so this widens the door, not what is behind it. + * + * KIOSK stays out: a kiosk login is a shared device bolted to a wall, and the + * portal is not what it is for. + */ +const PORTAL_ROLES = new Set([ + "EMPLOYEE", + "SUPER_ADMIN", + "COMPANY_ADMIN", + "HR_ADMIN", + "PAYROLL_ADMIN", + "FINANCE_ADMIN", + "BRANCH_MANAGER", + "TEAM_LEAD", + "AUDITOR", +]); + +export class NoManagerAccessError extends Error {} + +/** Firebase custom claim `r` (roles) is untyped — coerce to a string array. */ +function asRoles(raw: unknown): string[] { + return Array.isArray(raw) ? raw.map(String) : []; +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState("loading"); + const [me, setMe] = useState(null); + + useEffect(() => { + // Resolve the session on load and whenever Firebase auth state changes + // (e.g. token restored from persistence). GET /me gives roles + tenant. + return onAuthStateChanged(auth, async (user) => { + if (!user) { + setMe(null); + setStatus("signedOut"); + return; + } + try { + // A dedicated kiosk device account has no employee record; route it + // straight to the full-screen kiosk display from its token claims. + const claims = await user.getIdTokenResult(); + // Linumic staff. They have no employee record and no company, so /me + // would refuse them — the console is a different application that + // happens to be served from the same bundle. + if (claims.claims.vendor === true) { + setMe(null); + setStatus("vendor"); + return; + } + if (asRoles(claims.claims.r).includes("KIOSK")) { + setMe(null); + setStatus("kiosk"); + return; + } + const { data } = await api.get("/me"); + if (!data.roles.some((r) => PORTAL_ROLES.has(r))) { + await firebaseSignOut(auth); + setMe(null); + setStatus("signedOut"); + return; + } + setMe(data); + setStatus("signedIn"); + } catch (err) { + // A valid Firebase user with no /me (not provisioned) is signed out. + if (err instanceof ApiError) await firebaseSignOut(auth); + setMe(null); + setStatus("signedOut"); + } + }); + }, []); + + const value = useMemo( + () => ({ + status, + me, + signIn: async (email, password) => { + const cred = await signInWithEmailAndPassword(auth, email.trim(), password); + const claims = await cred.user.getIdTokenResult(); + if (claims.claims.vendor === true) { + setMe(null); + setStatus("vendor"); + return; + } + if (asRoles(claims.claims.r).includes("KIOSK")) { + setMe(null); + setStatus("kiosk"); + return; + } + const { data } = await api.get("/me"); + if (!data.roles.some((r) => PORTAL_ROLES.has(r))) { + await firebaseSignOut(auth); + throw new NoManagerAccessError(); + } + setMe(data); + setStatus("signedIn"); + }, + signOut: async () => { + await firebaseSignOut(auth); + setMe(null); + setStatus("signedOut"); + }, + refreshMe: async () => { + // Best-effort refresh; keep the current session on failure. Only applies + // to an already-signed-in manager, so the role gate is a safety no-op. + const { data } = await api.get("/me"); + if (data.roles.some((r) => PORTAL_ROLES.has(r))) setMe(data); + }, + }), + [status, me], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} + +/** Client-side permission check mirroring the server RBAC catalog (UX only). */ +export function useHasPermission(): (permission: string) => boolean { + const { me } = useAuth(); + return (permission: string) => { + if (!me) return false; + if (me.roles.includes("COMPANY_ADMIN") || me.roles.includes("SUPER_ADMIN")) return true; + return (ROLE_PERMISSIONS[permission] ?? []).some((role) => me.roles.includes(role)); + }; +} + +// Which roles grant each permission the portal gates on (subset of the server +// catalog in backend/functions/src/middleware/rbac.ts). COMPANY_ADMIN and +// SUPER_ADMIN bypass this map entirely (they hold "*"). +const ROLE_PERMISSIONS: Record = { + "employees:read": ["HR_ADMIN", "PAYROLL_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD", "AUDITOR"], + "employees:write": ["HR_ADMIN"], + "attendance:read": ["HR_ADMIN", "PAYROLL_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD", "AUDITOR"], + "attendance:approve": ["HR_ADMIN", "BRANCH_MANAGER"], + "leave:approve": ["HR_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD"], + "payroll:read": ["HR_ADMIN", "PAYROLL_ADMIN", "FINANCE_ADMIN", "AUDITOR"], + "payroll:run": ["PAYROLL_ADMIN", "FINANCE_ADMIN"], + "payroll:approve": ["PAYROLL_ADMIN", "FINANCE_ADMIN"], + "finance:read": ["FINANCE_ADMIN", "AUDITOR"], + "expenses:read": ["FINANCE_ADMIN", "AUDITOR"], + "expenses:write": ["FINANCE_ADMIN"], + "expenses:approve": ["FINANCE_ADMIN"], + "ledger:read": ["FINANCE_ADMIN", "AUDITOR"], + "ledger:write": ["FINANCE_ADMIN"], + "rosters:read": ["HR_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD"], + "work:read": ["HR_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD", "AUDITOR"], + "work:write": ["HR_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD"], + // Everyone who is somebody's employee — which is everyone who does the work, + // their team lead included. + "self:tasks": ["HR_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD", "EMPLOYEE"], + "rosters:write": ["HR_ADMIN", "BRANCH_MANAGER"], + "kiosk:issue": ["HR_ADMIN", "BRANCH_MANAGER"], + // settings:write is intentionally empty — only COMPANY_ADMIN/SUPER_ADMIN (the + // dedicated admin) may edit configuration, via the "*" bypass above. + "settings:write": [], +}; + +const DEFAULT_FEATURES: CompanyFeatures = { + shifts: true, + leave: true, + payroll: true, + regularization: true, + announcements: true, + geofencing: true, + qrKiosk: true, + faceRecognition: true, + finance: true, +}; + +/** Company feature flags (module toggles). Unknown → enabled, so nothing hides + * for a session created before the flags existed. */ +export function useFeatures(): CompanyFeatures { + const { me } = useAuth(); + return { ...DEFAULT_FEATURES, ...(me?.features ?? {}) }; +} diff --git a/web/src/auth/LoginPage.tsx b/web/src/auth/LoginPage.tsx new file mode 100644 index 0000000..39fe57f --- /dev/null +++ b/web/src/auth/LoginPage.tsx @@ -0,0 +1,330 @@ +import { useState, type FormEvent } from "react"; +import { FirebaseError } from "firebase/app"; +import { + sendEmailVerification, + signInWithEmailAndPassword, + signOut as firebaseSignOut, +} from "firebase/auth"; +import { auth } from "../firebase"; +import { NoManagerAccessError, useAuth } from "./AuthProvider"; +import { ApiError, signupCompany } from "../api/client"; +import { BUSINESS_TYPES } from "../api/businessTypes"; +import { useI18n } from "../i18n/LocaleProvider"; +import { LOCALES } from "../i18n/strings"; +import { ThemeToggle } from "../ui/ThemeProvider"; + +type Mode = "login" | "signup"; + +export function LoginPage() { + const { signIn } = useAuth(); + const { t, locale, setLocale } = useI18n(); + const [mode, setMode] = useState("login"); + + const [companyName, setCompanyName] = useState(""); + const [businessType, setBusinessType] = useState(""); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Set once a signup (or a login by an unverified admin) is waiting on the + // verification link; swaps the form for the "check your inbox" panel. + const [pendingEmail, setPendingEmail] = useState(null); + const [resent, setResent] = useState(false); + + /** + * Signs in far enough for Firebase to send its own verification mail, then + * signs straight back out. The backend refuses an unverified self-signup + * admin, so there is no session to keep — and Firebase sends the message + * itself, which is why no mail transport is configured anywhere. + */ + async function sendVerification(address: string, secret: string): Promise { + const cred = await signInWithEmailAndPassword(auth, address, secret); + await sendEmailVerification(cred.user); + await firebaseSignOut(auth); + } + + async function onResend() { + if (busy || !pendingEmail) return; + setBusy(true); + setError(null); + try { + await sendVerification(pendingEmail, password); + setResent(true); + } catch { + setError(t("common_error")); + } finally { + setBusy(false); + } + } + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + if (busy) return; + setBusy(true); + setError(null); + try { + if (mode === "signup") { + // Create the company + admin, then have Firebase mail the verification + // link. The admin cannot be signed in yet: the API gates a self-signup + // account until the address is proven. + await signupCompany({ + companyName, + // Empty means "did not say", which the server reads as the product + // defaults rather than as an error. + businessType: businessType || undefined, + adminFirstName: firstName, + adminLastName: lastName, + email, + password, + }); + await sendVerification(email.trim(), password); + setPendingEmail(email.trim()); + return; + } + await signIn(email, password); + } catch (err) { + if (err instanceof ApiError && err.code === "EMAIL_NOT_VERIFIED") { + // Signed in to Firebase but refused by the API. Drop the session and + // point them at the link rather than showing a bare error. + await firebaseSignOut(auth).catch(() => undefined); + setPendingEmail(email.trim()); + } else if (err instanceof NoManagerAccessError) setError(t("login_no_access")); + else if (err instanceof ApiError) + setError(err.code === "CONFLICT" ? t("signup_email_exists") : err.message); + else if (err instanceof FirebaseError) setError(t("login_error")); + else setError(t("common_error")); + } finally { + setBusy(false); + } + } + + const isSignup = mode === "signup"; + + return ( +
    +
    + +
    + + +
    +
    + {pendingEmail ? ( + <> +

    {t("verify_title")}

    +
    {t("verify_sent", pendingEmail)}
    +

    {t("verify_hint")}

    + + {error &&
    {error}
    } + {resent &&
    {t("verify_resent")}
    } + + + + + ) : ( + <> +

    {isSignup ? t("signup_title") : t("login_welcome")}

    +
    {isSignup ? t("signup_sub") : t("tagline")}
    + + {isSignup && ( + <> + + {/* One question, asked once. It only chooses starting values — + every one of them is on the settings page afterwards, and this + answer can be changed there too. */} +
    + + + + {t("signup_business_type_hint")} + +
    +
    + + +
    + + )} + + + + + {error &&
    {error}
    } + + + + + + )} + +
    + {LOCALES.map((l) => ( + + ))} +
    + +
    +
    + ); +} + +function Field({ + label, + value, + onChange, + type = "text", + autoComplete, + dir, + hint, + required, +}: { + label: string; + value: string; + onChange: (v: string) => void; + type?: string; + autoComplete?: string; + dir?: "ltr" | "rtl"; + hint?: string; + required?: boolean; +}) { + const { t } = useI18n(); + const [show, setShow] = useState(false); + const isPassword = type === "password"; + const inputType = isPassword && show ? "text" : type; + + return ( +
    + +
    + onChange(e.target.value)} + required={required} + style={isPassword ? { paddingInlineEnd: 44 } : undefined} + /> + {isPassword && ( + + )} +
    + {hint && {hint}} +
    + ); +} + +const IconEye = () => ( + +); +const IconEyeOff = () => ( + +); diff --git a/web/src/auth/PortalAccess.test.tsx b/web/src/auth/PortalAccess.test.tsx new file mode 100644 index 0000000..52def2d --- /dev/null +++ b/web/src/auth/PortalAccess.test.tsx @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { MemoryRouter } from "react-router-dom"; +import { LocaleProvider } from "../i18n/LocaleProvider"; +import { ThemeProvider } from "../ui/ThemeProvider"; + +/** + * Who gets into the portal, and what they land on. + * + * The door was opened to EMPLOYEE so somebody whose phone cannot run the app + * can still see their own work. Two things have to hold, and neither is + * visible from the markup: + * + * - an employee lands on their own work, not on the company dashboard, whose + * every request needs attendance:read and would fail; + * - opening the door does not put anything behind it within reach. + */ + +// Layout renders the theme toggle, and ThemeProvider asks the platform whether +// the system is dark. jsdom has no matchMedia. +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; + +const roles = vi.hoisted(() => ({ current: ["EMPLOYEE"] as string[] })); + +// The header now carries the notification bell, which queries. This test is +// about who gets which nav items, so the bell is kept inert rather than given +// a QueryClient it would only use to fetch nothing. +vi.mock("../api/hooks", () => ({ + useNotifications: () => ({ data: { items: [], unread: 0 } }), + useMarkNotificationRead: () => ({ mutateAsync: vi.fn(), isPending: false }), + useMarkAllNotificationsRead: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +const PERMS: Record = { + "attendance:read": ["HR_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD", "AUDITOR", "PAYROLL_ADMIN"], + "employees:read": ["HR_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD", "AUDITOR", "PAYROLL_ADMIN"], + "payroll:read": ["HR_ADMIN", "PAYROLL_ADMIN", "FINANCE_ADMIN", "AUDITOR"], + "leave:approve": ["HR_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD"], + "rosters:read": ["HR_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD"], + "finance:read": ["FINANCE_ADMIN", "AUDITOR"], + "devices:read": ["HR_ADMIN", "BRANCH_MANAGER"], + "kiosk:issue": ["HR_ADMIN", "BRANCH_MANAGER"], + "settings:write": [], + "work:read": ["HR_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD", "AUDITOR"], + "self:tasks": ["HR_ADMIN", "BRANCH_MANAGER", "TEAM_LEAD", "EMPLOYEE"], +}; + +vi.mock("../auth/AuthProvider", () => ({ + useAuth: () => ({ + me: { displayName: "Ali Rahimi", companyName: "Kabul Construction", companyId: "c1" }, + signOut: vi.fn(), + }), + useHasPermission: () => (p: string) => + roles.current.includes("COMPANY_ADMIN") || (PERMS[p] ?? []).some((r) => roles.current.includes(r)), + useFeatures: () => ({ + shifts: true, leave: true, payroll: true, regularization: true, + announcements: true, geofencing: true, qrKiosk: true, faceRecognition: false, finance: true, + }), +})); + +const { Layout } = await import("../ui/Layout"); + +function renderNav(): void { + render( + + + {() as ReactNode} + + , + ); +} + +/** Every nav destination the portal has, by the label it shows. */ +const MANAGER_ONLY = [ + "کارمندان", + "حاضری", + "شیفت‌ها", + "رخصتی‌ها", + "معاش", + "مالی", + "کیوسک", + "دستگاه‌ها و لایسنس", + "تنظیمات", + "داشبورد", +]; + +beforeEach(() => { + roles.current = ["EMPLOYEE"]; +}); + +describe("what an employee is offered in the portal", () => { + it("shows them their work and nothing else", () => { + renderNav(); + + expect(screen.getByRole("link", { name: /کار و پروژه/ })).toBeInTheDocument(); + for (const label of MANAGER_ONLY) { + expect(screen.queryByRole("link", { name: new RegExp(label) })).not.toBeInTheDocument(); + } + }); + + it("hides the dashboard link rather than offering one that bounces", () => { + // "/" redirects an employee to /work, so a visible Dashboard item would be + // a link that silently goes somewhere else. + renderNav(); + expect(screen.queryByRole("link", { name: /داشبورد/ })).not.toBeInTheDocument(); + }); +}); + +describe("what a manager still sees", () => { + it("keeps the full menu for an HR admin", () => { + roles.current = ["HR_ADMIN"]; + renderNav(); + + for (const label of ["داشبورد", "کارمندان", "حاضری", "کار و پروژه"]) { + expect(screen.getByRole("link", { name: new RegExp(label) })).toBeInTheDocument(); + } + }); + + it("still keeps finance away from an HR admin", () => { + // Opening the door to employees must not have loosened anything else. + roles.current = ["HR_ADMIN"]; + renderNav(); + expect(screen.queryByRole("link", { name: /مالی/ })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/auth/SignupBusinessType.test.tsx b/web/src/auth/SignupBusinessType.test.tsx new file mode 100644 index 0000000..220ca09 --- /dev/null +++ b/web/src/auth/SignupBusinessType.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { BUSINESS_TYPES } from "../api/businessTypes"; +import { LocaleProvider } from "../i18n/LocaleProvider"; +import { ThemeProvider } from "../ui/ThemeProvider"; +import { DICTIONARIES } from "../i18n/strings"; + +/** + * The one question signup asks about the work itself. + * + * What the server does with the answer is tested there. What only the form can + * get wrong is asking badly: a required question that blocks a signup, a name + * that shows as a raw key because one of three dictionaries was missed, or an + * empty answer sent as "" where the server expects nothing at all. + */ + +// ThemeProvider asks the platform whether the system is dark; jsdom has no +// matchMedia. +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; + +const sent = vi.hoisted(() => ({ bodies: [] as Record[] })); + +vi.mock("../api/client", () => ({ + ApiError: class extends Error { + code = "X"; + }, + signupCompany: async (body: Record) => { + sent.bodies.push(body); + return { companyId: "c1", employeeId: "e1" }; + }, +})); + +vi.mock("firebase/auth", () => ({ + signInWithEmailAndPassword: async () => ({ user: {} }), + sendEmailVerification: async () => {}, + signOut: async () => {}, +})); + +vi.mock("../firebase", () => ({ auth: {}, firebaseConfigured: true })); +vi.mock("./AuthProvider", () => ({ useAuth: () => ({ signIn: vi.fn() }) })); + +const { LoginPage } = await import("./LoginPage"); + +function openSignup(): void { + render( + + + + + + + , + ); + fireEvent.click(screen.getByText(DICTIONARIES.fa.signup_no_account)); +} + +function typeInto(label: string, value: string): void { + const field = screen.getByText(label).closest(".field") as HTMLElement; + fireEvent.change(field.querySelector("input")!, { target: { value } }); +} + +function typeSelect(): HTMLSelectElement { + const field = screen.getByText(DICTIONARIES.fa.signup_business_type).closest(".field") as HTMLElement; + return field.querySelector("select") as HTMLSelectElement; +} + +beforeEach(() => { + sent.bodies = []; +}); + +describe("the fifteen names", () => { + it("has one in every language, so nobody sees a raw key", () => { + // Three dictionaries edited by hand: a name added to Dari and forgotten in + // Pashto shows as "biz_clinic" to exactly the users least likely to report + // it. + for (const lang of ["fa", "ps", "en"] as const) { + for (const id of BUSINESS_TYPES) { + const key = `biz_${id.toLowerCase()}`; + const value = (DICTIONARIES[lang] as Record)[key]; + expect(value, `${lang} is missing ${key}`).toBeTruthy(); + expect(value).not.toBe(key); + } + } + }); + + it("offers every type the server knows about", () => { + openSignup(); + const options = [...typeSelect().options].map((o) => o.value).filter(Boolean); + expect(options).toEqual([...BUSINESS_TYPES]); + }); +}); + +describe("asking without blocking", () => { + it("starts unanswered and can be left that way", () => { + openSignup(); + expect(typeSelect().value).toBe(""); + expect(typeSelect().required).toBe(false); + }); + + it("sends nothing at all when it was not answered", async () => { + // "" is not the same as absent: the server reads absent as "give them the + // product defaults". + openSignup(); + typeInto(DICTIONARIES.fa.signup_company, "Kabul Traders"); + typeInto(DICTIONARIES.fa.signup_admin_first, "Ahmad"); + typeInto(DICTIONARIES.fa.signup_admin_last, "Karimi"); + typeInto(DICTIONARIES.fa.login_email, "a@example.com"); + typeInto(DICTIONARIES.fa.login_password, "Passw0rd!"); + fireEvent.submit(document.querySelector("form")!); + + await waitFor(() => expect(sent.bodies).toHaveLength(1)); + expect(sent.bodies[0].businessType).toBeUndefined(); + }); + + it("sends the choice when one is made", async () => { + openSignup(); + typeInto(DICTIONARIES.fa.signup_company, "Darulaman Construction"); + typeInto(DICTIONARIES.fa.signup_admin_first, "Ahmad"); + typeInto(DICTIONARIES.fa.signup_admin_last, "Karimi"); + typeInto(DICTIONARIES.fa.login_email, "b@example.com"); + typeInto(DICTIONARIES.fa.login_password, "Passw0rd!"); + fireEvent.change(typeSelect(), { target: { value: "CONSTRUCTION" } }); + fireEvent.submit(document.querySelector("form")!); + + await waitFor(() => expect(sent.bodies).toHaveLength(1)); + expect(sent.bodies[0].businessType).toBe("CONSTRUCTION"); + }); +}); diff --git a/web/src/firebase.ts b/web/src/firebase.ts new file mode 100644 index 0000000..f815184 --- /dev/null +++ b/web/src/firebase.ts @@ -0,0 +1,32 @@ +import { initializeApp } from "firebase/app"; +import { connectAuthEmulator, getAuth, type Auth } from "firebase/auth"; + +// Public web config — safe to ship in the client bundle. Access control is +// enforced by the API (bearer token + RBAC), not by hiding these values. +const config = { + apiKey: import.meta.env.VITE_FIREBASE_API_KEY, + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, + appId: import.meta.env.VITE_FIREBASE_APP_ID, +}; + +/** + * True only when the Firebase web config is filled in (.env.local). When false + * the app renders a setup screen instead of initializing Firebase — otherwise + * getAuth() throws on an empty apiKey and the whole page white-screens. + */ +export const firebaseConfigured = Boolean(config.apiKey && config.projectId); + +const useEmulators = import.meta.env.VITE_USE_EMULATORS === "true"; + +// A stub is fine when unconfigured: the auth-dependent tree is never mounted +// in that case (see main.tsx), so `auth` is never actually touched. +export const auth: Auth = firebaseConfigured + ? getAuth(initializeApp(config)) + : ({} as Auth); + +// Local demo: talk to the Auth emulator instead of production Firebase, so +// the seeded demo users (see backend/functions/seed.js) can sign in. +if (firebaseConfigured && useEmulators) { + connectAuthEmulator(auth, "http://127.0.0.1:9099", { disableWarnings: true }); +} diff --git a/web/src/i18n/LocaleProvider.test.tsx b/web/src/i18n/LocaleProvider.test.tsx new file mode 100644 index 0000000..36ccb8d --- /dev/null +++ b/web/src/i18n/LocaleProvider.test.tsx @@ -0,0 +1,135 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, renderHook } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { LocaleProvider, useI18n } from "./LocaleProvider"; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe("LocaleProvider defaults", () => { + it("defaults to Dari (fa) with RTL direction", () => { + const { result } = renderHook(() => useI18n(), { wrapper }); + expect(result.current.locale).toBe("fa"); + expect(result.current.dir).toBe("rtl"); + expect(document.documentElement.dir).toBe("rtl"); + expect(document.documentElement.lang).toBe("fa"); + }); + + it("restores a persisted locale from localStorage", () => { + localStorage.setItem("worktrack.locale", "en"); + const { result } = renderHook(() => useI18n(), { wrapper }); + expect(result.current.locale).toBe("en"); + expect(result.current.dir).toBe("ltr"); + }); + + it("ignores an invalid persisted locale and falls back to fa", () => { + localStorage.setItem("worktrack.locale", "de"); + const { result } = renderHook(() => useI18n(), { wrapper }); + expect(result.current.locale).toBe("fa"); + }); +}); + +describe("t (translation + interpolation)", () => { + it("translates a key in the active locale", () => { + localStorage.setItem("worktrack.locale", "en"); + const { result } = renderHook(() => useI18n(), { wrapper }); + expect(result.current.t("nav_dashboard")).toBe("Dashboard"); + }); + + it("interpolates positional {0} placeholders", () => { + localStorage.setItem("worktrack.locale", "en"); + const { result } = renderHook(() => useI18n(), { wrapper }); + expect(result.current.t("pay_run_done", 5)).toBe("Payroll calculated for 5 employees"); + }); + + it("returns the key itself when no translation exists", () => { + const { result } = renderHook(() => useI18n(), { wrapper }); + expect(result.current.t("totally_missing_key")).toBe("totally_missing_key"); + }); +}); + +describe("num (digit localization)", () => { + it("converts Latin digits to Eastern Arabic digits for fa", () => { + const { result } = renderHook(() => useI18n(), { wrapper }); + expect(result.current.num(2026)).toBe("۲۰۲۶"); + }); + + it("leaves digits untouched for en", () => { + localStorage.setItem("worktrack.locale", "en"); + const { result } = renderHook(() => useI18n(), { wrapper }); + expect(result.current.num("Page 12")).toBe("Page 12"); + }); +}); + +describe("shamsi (ISO -> Solar Hijri label)", () => { + it("formats an ISO date with the localized month name in English", () => { + localStorage.setItem("worktrack.locale", "en"); + const { result } = renderHook(() => useI18n(), { wrapper }); + expect(result.current.shamsi("2026-07-17")).toBe("26 Saratan"); + expect(result.current.shamsi("2026-07-17", { withYear: true })).toBe("26 Saratan 1405"); + }); + + it("uses Eastern digits and the Dari month name for fa", () => { + const { result } = renderHook(() => useI18n(), { wrapper }); + expect(result.current.shamsi("2026-07-17")).toBe("۲۶ سرطان"); + }); +}); + +describe("shamsiMonthName", () => { + it("returns the localized month name (1-based)", () => { + localStorage.setItem("worktrack.locale", "en"); + const { result } = renderHook(() => useI18n(), { wrapper }); + expect(result.current.shamsiMonthName(1)).toBe("Hamal"); + expect(result.current.shamsiMonthName(12)).toBe("Hut"); + }); + + it("clamps out-of-range month numbers", () => { + localStorage.setItem("worktrack.locale", "en"); + const { result } = renderHook(() => useI18n(), { wrapper }); + expect(result.current.shamsiMonthName(0)).toBe("Hamal"); + expect(result.current.shamsiMonthName(99)).toBe("Hut"); + }); +}); + +describe("setLocale", () => { + it("switches locale, persists it, and updates the document direction", async () => { + const user = userEvent.setup(); + + function Probe() { + const { locale, t, setLocale } = useI18n(); + return ( +
    + {t("nav_dashboard")} + {locale} + +
    + ); + } + + render( + + + , + ); + + expect(screen.getByTestId("locale")).toHaveTextContent("fa"); + + await user.click(screen.getByRole("button", { name: "english" })); + + expect(screen.getByTestId("locale")).toHaveTextContent("en"); + expect(screen.getByTestId("label")).toHaveTextContent("Dashboard"); + expect(localStorage.getItem("worktrack.locale")).toBe("en"); + expect(document.documentElement.dir).toBe("ltr"); + }); +}); + +describe("useI18n outside a provider", () => { + it("throws a helpful error", () => { + // Silence the expected React error-boundary console noise for this case. + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => renderHook(() => useI18n())).toThrow(/must be used within LocaleProvider/); + spy.mockRestore(); + }); +}); diff --git a/web/src/i18n/LocaleProvider.tsx b/web/src/i18n/LocaleProvider.tsx new file mode 100644 index 0000000..014bde9 --- /dev/null +++ b/web/src/i18n/LocaleProvider.tsx @@ -0,0 +1,96 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { DICTIONARIES, LOCALES, type Locale } from "./strings"; +import { toShamsi, type ShamsiDate } from "../shamsi/solarHijri"; + +const SHAMSI_MONTHS: Record = { + fa: ["حمل", "ثور", "جوزا", "سرطان", "اسد", "سنبله", "میزان", "عقرب", "قوس", "جدی", "دلو", "حوت"], + ps: ["وری", "غویی", "غبرګولی", "چنګاښ", "زمری", "وږی", "تله", "لړم", "لیندۍ", "مرغومی", "سلواغه", "کب"], + en: ["Hamal", "Sawr", "Jawza", "Saratan", "Asad", "Sunbula", "Mizan", "Aqrab", "Qaws", "Jadi", "Dalw", "Hut"], +}; + +const EASTERN_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"]; + +interface LocaleContextValue { + locale: Locale; + dir: "rtl" | "ltr"; + setLocale: (locale: Locale) => void; + /** Translate a key, with optional {0},{1} interpolation. */ + t: (key: string, ...args: (string | number)[]) => string; + /** Localize Latin digits to ۰–۹ for fa/ps. */ + num: (value: string | number) => string; + /** Format an ISO date as a Solar Hijri string, e.g. "۲۶ سرطان ۱۴۰۵". */ + shamsi: (isoDate: string, opts?: { withYear?: boolean }) => string; + shamsiMonthName: (month: number) => string; +} + +const LocaleContext = createContext(null); + +const STORAGE_KEY = "worktrack.locale"; + +export function LocaleProvider({ children }: { children: ReactNode }) { + const [locale, setLocaleState] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY) as Locale | null; + return stored && LOCALES.some((l) => l.code === stored) ? stored : "fa"; + }); + + const dir = LOCALES.find((l) => l.code === locale)?.dir ?? "rtl"; + + useEffect(() => { + document.documentElement.lang = locale; + document.documentElement.dir = dir; + }, [locale, dir]); + + const setLocale = useCallback((next: Locale) => { + localStorage.setItem(STORAGE_KEY, next); + setLocaleState(next); + }, []); + + const num = useCallback( + (value: string | number): string => { + const s = String(value); + if (locale === "en") return s; + return s.replace(/[0-9]/g, (d) => EASTERN_DIGITS[Number(d)]); + }, + [locale], + ); + + const shamsiMonthName = useCallback( + (month: number): string => SHAMSI_MONTHS[locale][Math.min(Math.max(month - 1, 0), 11)], + [locale], + ); + + const value = useMemo(() => { + const dict = DICTIONARIES[locale]; + const t = (key: string, ...args: (string | number)[]): string => { + let text = dict[key] ?? key; + args.forEach((arg, i) => { + text = text.replace(`{${i}}`, String(arg)); + }); + return text; + }; + const shamsi = (isoDate: string, opts?: { withYear?: boolean }): string => { + const d: ShamsiDate = toShamsi(isoDate); + const base = `${d.day} ${SHAMSI_MONTHS[locale][d.month - 1]}${ + opts?.withYear ? ` ${d.year}` : "" + }`; + return locale === "en" ? base : base.replace(/[0-9]/g, (n) => EASTERN_DIGITS[Number(n)]); + }; + return { locale, dir, setLocale, t, num, shamsi, shamsiMonthName }; + }, [locale, dir, setLocale, num, shamsiMonthName]); + + return {children}; +} + +export function useI18n(): LocaleContextValue { + const ctx = useContext(LocaleContext); + if (!ctx) throw new Error("useI18n must be used within LocaleProvider"); + return ctx; +} diff --git a/web/src/i18n/keysUsed.test.ts b/web/src/i18n/keysUsed.test.ts new file mode 100644 index 0000000..8b3c28c --- /dev/null +++ b/web/src/i18n/keysUsed.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { DICTIONARIES, type Locale } from "./strings"; + +/** + * Every key the portal asks for exists, in every language. + * + * `t()` falls back to returning the key itself, so a missing string is not an + * error — it is the literal text `common_close` sitting on a button, shipped, + * with every test green. Two of those went out this week and both were caught + * by a person looking at the screen, which is not a system. + * + * The iOS app has had ios/check-strings.py doing this since it was built. This + * is the same audit for the portal, as a test so it runs on every change. + */ + +// Resolved from the project root rather than from import.meta.url: vitest +// rewrites module URLs, and the relative form resolved to "/src". +const SRC = join(process.cwd(), "src"); + +function sourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) { + sourceFiles(path, out); + } else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) { + out.push(path); + } + } + return out; +} + +/** + * Literal `t("…")` calls only. + * + * Keys built at runtime — t(`role_${r.toLowerCase()}`) — cannot be checked this + * way and are covered by the tests that render those lists. Matching them + * loosely here would produce false failures and get the audit switched off, + * which is worse than the gap. + */ +function keysIn(source: string): string[] { + return [...source.matchAll(/\bt\(\s*"([a-z0-9_]+)"/g)].map((m) => m[1]); +} + +const LANGUAGES: Locale[] = ["fa", "ps", "en"]; + +describe("translation keys", () => { + const used = new Map(); + for (const file of sourceFiles(SRC)) { + for (const key of keysIn(readFileSync(file, "utf8"))) { + used.set(key, [...(used.get(key) ?? []), file.replace(SRC, "")]); + } + } + + it("finds a meaningful number of them, or the scanner is broken", () => { + // A regex that silently matched nothing would make every assertion below + // pass while checking not one thing. + expect(used.size).toBeGreaterThan(200); + }); + + it("has every used key in all three dictionaries", () => { + const missing: string[] = []; + for (const [key, files] of used) { + for (const lang of LANGUAGES) { + const value = (DICTIONARIES[lang] as Record)[key]; + if (!value) missing.push(`${lang}: ${key} (${files[0]})`); + } + } + expect(missing, `\n${missing.join("\n")}\n`).toEqual([]); + }); + + it("never uses the key itself as the translation", () => { + // Filling a gap with the key silences this audit while leaving the same + // text on screen. + const lazy: string[] = []; + for (const lang of LANGUAGES) { + for (const [key, value] of Object.entries(DICTIONARIES[lang])) { + if (value === key) lazy.push(`${lang}: ${key}`); + } + } + expect(lazy).toEqual([]); + }); +}); diff --git a/web/src/i18n/strings.ts b/web/src/i18n/strings.ts new file mode 100644 index 0000000..1d89c6f --- /dev/null +++ b/web/src/i18n/strings.ts @@ -0,0 +1,2112 @@ +// Trilingual UI strings for the manager portal. Dari is the default; Pashto and +// English are full alternates. Keys mirror the Android app's resource names +// where practical. Interpolation uses {0}, {1} placeholders. + +export type Locale = "fa" | "ps" | "en"; + +export const LOCALES: { code: Locale; label: string; dir: "rtl" | "ltr" }[] = [ + { code: "fa", label: "دری", dir: "rtl" }, + { code: "ps", label: "پښتو", dir: "rtl" }, + { code: "en", label: "English", dir: "ltr" }, +]; + +type Dict = Record; + +const fa: Dict = { + app_title: "پورتال مدیر WorkTrack", + tagline: "مدیریت هوشمند نیروی کار برای افغانستان", + brand_tagline: "سامانه منابع بشری", + + nav_menu: "منو", + theme_to_dark: "حالت تاریک", + theme_to_light: "حالت روشن", + nav_dashboard: "داشبورد", + nav_employees: "کارمندان", + nav_attendance: "حاضری", + nav_leave: "رخصتی‌ها", + nav_payroll: "معاش", + nav_logout: "خروج", + + pay_title: "معاش", + pay_run: "اجرای معاش", + pay_running: "در حال محاسبه…", + pay_period: "دوره", + pay_year: "سال", + pay_month: "ماه", + pay_status: "وضعیت", + pay_employees: "کارمندان", + pay_total_gross: "مجموع ناخالص", + pay_total_net: "مجموع خالص", + pay_runs_empty: "هنوز معاشی اجرا نشده. یک ماه را انتخاب کنید و «اجرای معاش» را بزنید.", + pay_run_done: "معاش برای {0} کارمند محاسبه شد", + pay_exited_title: "{0} نفر که «خارج شده» اند در این ماه کار کرده‌اند", + pay_exited_body: "برای کارمند خارج‌شده فیش صادر نمی‌شود. اگر باید معاش این ماه را بگیرند، حالت‌شان را دوباره «فعال» کنید، معاش را دوباره محاسبه کنید، بعد «خارج شده» کنید.", + pay_skipped_title: "{0} کارمند در این محاسبه نیامدند", + pay_skipped_body: "برای این افراد معاش پایه ثبت نشده است. در صفحهٔ کارمندان معاش‌شان را ثبت کنید و دوباره محاسبه کنید.", + pay_provisional: "موقت", + pay_provisional_hint: "این ماه هنوز تمام نشده. ارقام فقط روزهای سپری‌شده را در بر می‌گیرد؛ پس از پایان ماه دوباره اجرا کنید.", + pay_view: "مشاهده", + pay_employee: "کارمند", + pay_gross: "ناخالص", + pay_deductions: "کسرات", + pay_net: "خالص", + pay_worked_days: "روزهای کارکرد", + pay_tax: "مالیه", + pay_ctc: "هزینهٔ کل شرکت", + pay_total_tax: "مجموع مالیه", + pay_back_to_runs: "بازگشت به دوره‌ها", + + login_email: "ایمیل کاری", + login_password: "رمز عبور", + login_submit: "ورود", + login_error: "ایمیل یا رمز عبور نادرست است", + login_no_access: "این حساب به پورتال دسترسی ندارد", + login_signing_in: "در حال ورود…", + login_show_password: "نمایش رمز", + login_hide_password: "پنهان‌کردن رمز", + login_welcome: "خوش آمدید", + auth_point_1: "حاضری با GPS، QR و کیوسک", + auth_point_2: "معاش، رخصتی و اصلاح حاضری", + auth_point_3: "تقویم شمسی و گزارش‌های زنده", + + signup_title: "ثبت‌نام شرکت", + signup_sub: "فضای کاری شرکت خود را در چند ثانیه بسازید", + signup_company: "نام شرکت", + signup_admin_first: "نام مدیر", + signup_admin_last: "تخلص مدیر", + signup_submit: "ایجاد فضای کاری", + signup_creating: "در حال ایجاد…", + signup_no_account: "شرکت جدید؟ ثبت‌نام کنید", + signup_have_account: "حساب دارید؟ وارد شوید", + signup_email_exists: "این ایمیل قبلاً ثبت شده است", + signup_password_hint: "حداقل ۸ حرف", + verify_title: "ایمیل خود را تأیید کنید", + verify_sent: "لینک تأیید به {0} فرستاده شد.", + verify_hint: "روی لینک کلیک کنید و بعد وارد شوید. اگر در صندوق ورودی نبود، پوشهٔ اسپم را ببینید.", + verify_resend: "ارسال دوبارهٔ لینک", + verify_resent: "لینک دوباره فرستاده شد.", + verify_back: "بازگشت به ورود", + + dash_title: "نمای کلی امروز", + dash_active_employees: "کارمندان فعال", + dash_present: "حاضر", + dash_absent: "غیرحاضر", + dash_on_leave: "در رخصتی", + dash_late: "ناوقت", + dash_half_day: "نیم روز", + dash_pending_leave: "رخصتی‌های در انتظار", + dash_attendance_rate: "نرخ حاضری", + dash_trend: "روند حاضری (۷ روز)", + + emp_title: "کارمندان", + emp_add: "افزودن کارمند", + emp_edit: "ویرایش", + emp_edit_title: "ویرایش کارمند", + emp_updated: "کارمند به‌روزرسانی شد", + emp_login: "حساب ورود", + emp_reset_password: "بازنشانی رمز", + emp_set_password: "تعیین رمز", + emp_set_password_ph: "رمز دلخواه (خالی = تصادفی، حداقل ۸ حرف)", + emp_code: "کود", + emp_code_auto: "خودکار", + emp_role_unchanged: "بدون تغییر", + adv_title: "پیش‌پرداخت‌ها", + adv_sub: "پولی که پیش از معاش به کارمند داده شده و از معاش کم می‌شود", + adv_add: "ثبت پیش‌پرداخت", + adv_empty: "پیش‌پرداختی ثبت نشده", + adv_employee: "کارمند", + adv_pick_employee: "کارمند را انتخاب کنید", + adv_principal: "مبلغ", + adv_instalment: "قسط ماهانه", + adv_instalment_hint: "خالی بگذارید تا در معاش بعدی یک‌جا کسر شود", + adv_in_full: "یک‌جا", + adv_outstanding: "باقی‌مانده", + adv_issued: "تاریخ", + adv_status: "وضعیت", + adv_status_outstanding: "باقی‌مانده", + adv_status_settled: "تسویه شد", + adv_status_cancelled: "لغو شد", + adv_cancel: "لغو", + adv_cancel_confirm: "پیش‌پرداخت {0} لغو شود؟ این کار برگشت ندارد.", + adv_cancelled: "پیش‌پرداخت لغو شد", + adv_saved: "پیش‌پرداخت ثبت شد", + adv_total_owed: "مجموع باقی‌مانده: {0}", + adv_note: "کسر در اجرای بعدی معاش انجام می‌شود، پس از مالیه. اگر معاش ماه کفاف ندهد، همان‌قدر که ممکن است کم می‌شود و بقیه به ماه بعد می‌ماند — معاش هرگز منفی نمی‌شود.", + adv_note_field: "یادداشت", + doc_title: "اسناد", + doc_empty: "سندی ثبت نشده", + doc_add: "افزودن سند", + doc_delete: "حذف", + doc_type: "نوع سند", + doc_number: "شماره", + doc_expires_on: "تاریخ انقضا", + doc_expires_hint: "خالی بگذارید اگر سند انقضا ندارد (مثل تذکره)", + doc_no_expiry: "بدون انقضا", + doc_expires: "تا {0}", + doc_expired: "منقضی شده — {0}", + doc_type_tazkira: "تذکره", + doc_type_contract: "قرارداد کار", + doc_type_work_permit: "جواز کار", + doc_type_health_certificate: "گواهی صحی", + doc_type_licence: "لایسنس", + doc_type_other: "سایر", + notif_title: "اعلان‌ها", + notif_empty: "اعلانی نیست", + notif_mark_all: "همه خوانده شد", + notif_unread: "{0} اعلان خوانده‌نشده", + piece_title: "کار کارمزدی", + piece_sub: "تعداد قطعاتی که هر کارمند تمام کرده — برای کسانی که کارمزدی کار می‌کنند، همین مزدشان است", + piece_add: "ثبت تعداد", + piece_empty: "تعدادی ثبت نشده", + piece_employee: "کارمند", + piece_pick_employee: "کارمند را انتخاب کنید", + piece_date: "تاریخ", + piece_quantity: "تعداد", + piece_note: "یادداشت", + piece_delete: "حذف", + piece_delete_confirm: "این ثبت برای {0} حذف شود؟", + piece_deleted: "حذف شد", + piece_saved: "ثبت شد", + piece_err_required: "کارمند و تعداد لازم است", + emp_pay_model: "نوع پرداخت", + pay_model_monthly: "ماهانه", + pay_model_daily: "روزمزد", + pay_model_piece: "کارمزدی", + pay_model_hint_monthly: "معاش ثابت ماهانه؛ روزهای غیرحاضری از آن کم می‌شود.", + pay_model_hint_daily: "مزد هر روز کاری. روزی که نیامده، پرداخت نمی‌شود — و جداگانه هم کسر نمی‌شود.", + pay_model_hint_piece: "مزد به‌ازای هر قطعه. تعداد را در صفحهٔ معاش ثبت کنید.", + emp_rate_daily: "مزد روزانه ({0})", + emp_rate_piece: "مزد هر قطعه ({0})", + pay_rate_monthly: "معاش ماهانه", + pay_rate_daily: "مزد روزانه", + pay_rate_piece: "مزد هر قطعه", + sheet_open: "برگهٔ پرداخت", + sheet_csv: "خروجی اکسل", + sheet_print: "چاپ", + sheet_title: "برگهٔ پرداخت معاش", + sheet_period: "دورهٔ {0}", + sheet_row: "ش", + sheet_code: "کود", + sheet_name: "نام و تخلص", + sheet_gross: "ناخالص", + sheet_deductions: "کسرات", + sheet_net: "قابل پرداخت", + sheet_signature: "امضا / اثر انگشت", + sheet_total: "مجموع — {0} نفر", + sheet_paid_by: "پرداخت‌کننده:", + sheet_approved_by: "تأییدکننده:", + adv_err_required: "کارمند و مبلغ لازم است", + adv_err_instalment: "قسط باید عدد مثبت باشد", + adv_err_instalment_big: "قسط از خود مبلغ بیشتر است", + signup_business_type: "نوع کسب‌وکار", + signup_business_type_skip: "انتخاب کنید (اختیاری)", + signup_business_type_hint: "فقط تنظیمات اولیه را انتخاب می‌کند؛ همه‌شان بعداً قابل تغییرند.", + biz_office: "دفتر اداری", + biz_construction: "شرکت ساختمانی", + biz_retail: "فروشگاه", + biz_tailoring: "کارگاه خیاطی", + biz_warehouse: "انبار و لوژستیک", + biz_security: "شرکت امنیتی", + biz_restaurant: "رستوران و کافه", + biz_clinic: "کلینیک و دواخانه", + biz_school: "مکتب و کورس", + biz_ngo: "مؤسسه و پروژه", + biz_exchange: "صرافی", + biz_transport: "شرکت ترانسپورتی", + biz_production: "تولیدی و نانوایی", + biz_agriculture: "زراعت و مالداری", + biz_hospitality: "هوتل و مهمان‌خانه", + emp_name: "نام", + emp_email: "ایمیل", + emp_branch: "شعبه", + emp_status: "وضعیت", + emp_face: "چهره", + emp_face_enrolled: "ثبت‌شده", + emp_face_not_enrolled: "ثبت‌نشده", + emp_face_reset: "بازنشانی", + emp_face_reset_done: "ثبت چهره پاک شد", + emp_basic_salary: "معاش اساسی ماهانه ({0})", + emp_basic_salary_ph: "مثلاً ۳۰۰۰۰", + emp_basic_salary_hint: "بدون معاش اساسی، این کارمند در اجرای معاش فیش نمی‌گیرد.", + emp_type: "نوع استخدام", + emp_join_date: "تاریخ شمولیت", + emp_phone: "تلیفون", + emp_save: "ذخیره", + emp_cancel: "لغو", + emp_created: "کارمند اضافه شد", + emp_search: "جستجوی نام یا کود…", + emp_load_more: "بارگذاری بیشتر", + emp_empty: "کارمندی یافت نشد", + emp_role: "نقش", + emp_create_login: "ساخت حساب ورود برای اپ موبایل", + emp_password_optional: "رمز (خالی = رمز موقت خودکار)", + emp_credentials_title: "حساب کارمند ساخته شد", + emp_credentials_hint: "این معلومات را به کارمند بدهید تا وارد اپ موبایل شود:", + emp_credentials_email: "ایمیل", + emp_credentials_password: "رمز موقت", + emp_credentials_copy: "کپی", + emp_credentials_copied: "کپی شد", + emp_credentials_done: "تمام", + role_employee: "کارمند", + role_team_lead: "سرگروپ", + role_branch_manager: "مدیر شعبه", + role_hr_admin: "مدیر منابع بشری", + role_payroll_admin: "مدیر معاش", + role_auditor: "بازرس", + + status_active: "فعال", + status_on_leave: "در رخصتی", + status_suspended: "معلق", + status_exited: "خارج شده", + + type_full_time: "تمام‌وقت", + type_part_time: "نیمه‌وقت", + type_contract: "قراردادی", + type_intern: "کارآموز", + + att_title: "مانیتورینگ حاضری", + att_date: "تاریخ", + att_present: "حاضر", + att_absent: "غیرحاضر", + att_first_in: "اولین ورود", + att_worked: "کارکرد", + att_late_by: "{0} دقیقه ناوقت", + att_empty: "برای این روز معلوماتی نیست", + att_needs_review: "نیاز به بررسی", + att_needs_review_hint: "حاضری بدون تأیید چهره ثبت شده است", + att_face_verified: "چهره تأیید شد", + att_rejected: "ثبت نشد", + att_rejected_hint: "{0} — در ساعت {1} تلاش شد ولی حساب نشد", + att_rejected_count: "{0} پانچ رد شده", + att_reason_geofence: "خارج از محدودهٔ کاری", + att_reason_time_skew: "ساعت دستگاه نادرست است", + att_reason_too_old: "خیلی دیر ارسال شد", + att_reason_travel: "جابه‌جایی غیرممکن", + att_reason_kiosk: "کد کیوسک نامعتبر", + att_reason_unknown: "دلیل نامشخص", + att_inactive: "غیرفعال", + att_company_time: "به وقت شرکت", + att_view_selfie: "دیدن عکس ورود", + att_view_daily: "روزانه", + att_view_weekly: "هفتگی", + att_week_total: "مجموع هفته", + + reg_title: "درخواست‌های اصلاح حاضری", + reg_pending: "اصلاح‌های در انتظار", + reg_date: "تاریخ", + reg_requested_in: "ورود پیشنهادی", + reg_requested_out: "خروج پیشنهادی", + reg_reason: "دلیل", + reg_approve: "تایید", + reg_reject: "رد", + reg_empty: "درخواست اصلاح حاضری در انتظار نیست", + reg_approved: "اصلاح تایید شد", + reg_rejected: "اصلاح رد شد", + reg_reject_prompt: "دلیل رد را بنویسید:", + + nav_shifts: "شیفت‌ها", + nav_kiosk: "کیوسک", + nav_settings: "تنظیمات", + dz_title: "بستن حساب شرکت", + dz_hint: "حساب شرکت و تمام داده‌های آن حذف می‌شود. {0} روز فرصت دارید نظرتان را عوض کنید؛ بعد از آن برگشت‌پذیر نیست.", + dz_start: "بستن حساب شرکت", + dz_confirm_body: "با این کار برای همیشه از دست می‌رود:", + dz_loses_attendance: "تمام سوابق حاضری و رخصتی", + dz_loses_payroll: "تمام اجراهای معاش، فیش‌ها و دفترکل", + dz_loses_logins: "حساب ورود همهٔ کارمندان و کیوسک‌ها", + dz_type_name: "برای تأیید، نام شرکت را دقیقاً بنویسید: {0}", + dz_reason: "دلیل (اختیاری)", + dz_reason_ph: "برای سوابق خودتان", + dz_confirm: "بله، حساب را ببند", + dz_scheduled: "بستن حساب زمان‌بندی شد", + dz_cancelled: "لغو شد — حساب دوباره فعال است", + dz_scheduled_title: "این حساب برای بسته شدن زمان‌بندی شده", + dz_scheduled_body: "داده‌ها در {0} حذف می‌شوند. تا آن روز هر وقت بخواهید می‌توانید لغو کنید و همه‌چیز دست‌نخورده برمی‌گردد.", + dz_cancel: "لغو و فعال‌سازی دوباره", + att_weekend: "امروز رخصتی هفته‌وار است", + att_holiday: "امروز تعطیل رسمی است", + att_closed_hint: "کسی امروز حاضری ندارد و این روز غیبت حساب نمی‌شود.", + hol_title: "تقویم کاری", + hol_hint: "روزهایی که شرکت تعطیل است. معاش این روزها و روزهای تعطیل هفته را غیبت حساب نمی‌کند.", + hol_year: "سال", + hol_seed: "ساخت تعطیلات ثابت سال", + hol_seeded: "{0} تعطیلی اضافه شد", + hol_seed_none: "چیزی برای اضافه کردن نبود", + hol_date: "تاریخ", + hol_name: "مناسبت", + hol_name_ph: "مثلاً عید فطر", + hol_paid: "با معاش", + hol_paid_yes: "با معاش", + hol_paid_no: "بدون معاش", + hol_add: "افزودن", + hol_remove: "حذف", + hol_saved: "ذخیره شد", + hol_empty: "برای این سال تعطیلی ثبت نشده", + hol_generated: "خودکار", + hol_lunar_note: "نوروز و روز استقلال در تقویم شمسی ثابت‌اند و خودکار ساخته می‌شوند. عید فطر، عید قربان، عاشورا و میلادالنبی قمری‌اند و تاریخشان با رؤیت هلال اعلام می‌شود — آن‌ها را هر سال خودتان اضافه کنید.", + nav_devices: "دستگاه‌ها و لایسنس", + nav_work: "کار و پروژه", + + work_title: "کار و پروژه", + work_intro: "هر کارمند باید بداند امروز و فردا روی کدام بخش کار می‌کند. اینجا آن را مشخص می‌کنید.", + work_tab_board: "کار امروز", + work_tab_plan: "برنامه", + work_tab_projects: "پروژه‌ها", + work_tab_teams: "تیم‌ها", + work_tab_mine: "کار من", + + work_board_title: "چه کسی روی چه کاری است", + work_board_empty: "برای این روز هنوز کاری تعیین نشده.", + work_prev_day: "روز قبل", + work_next_day: "روز بعد", + work_date: "تاریخ", + work_assign: "تعیین کار", + work_person: "کارمند", + work_task: "کار", + work_items: "کار", + + work_plan_title: "برنامهٔ کاری", + work_plan_empty: "در این بازه کاری تعیین نشده.", + work_from: "از تاریخ", + work_to_optional: "تا تاریخ (اختیاری)", + work_span: "بازه", + work_span_day: "یک روز", + work_span_week: "یک هفته", + work_span_month: "یک ماه", + work_project: "پروژه", + work_all_projects: "همهٔ پروژه‌ها", + work_when: "زمان", + work_who: "مسئول", + work_status: "وضعیت", + work_edit: "ویرایش", + work_delete: "حذف", + work_delete_confirm: "«{0}» حذف شود؟", + work_deleted: "حذف شد", + work_saved: "ثبت شد", + + work_projects_title: "پروژه‌ها", + work_projects_hint: "قراردادها و ساحه‌های کاری شرکت. کار به پروژه تعلق می‌گیرد.", + work_projects_empty: "هنوز پروژه‌ای ثبت نشده.", + work_project_add: "پروژهٔ جدید", + work_project_edit: "ویرایش پروژه", + work_project_name: "نام پروژه", + work_code: "کود", + work_detail: "توضیح", + work_choose_project: "پروژه را انتخاب کنید", + work_pstatus_planned: "پلان‌شده", + work_pstatus_active: "جاری", + work_pstatus_paused: "متوقف", + work_pstatus_done: "تکمیل", + + work_teams_title: "تیم‌های کاری", + work_teams_hint: "یک تیم گروهی از کارمندان است، نه یک بخش. کار تیمی به همهٔ اعضا می‌رسد.", + work_teams_empty: "هنوز تیمی ساخته نشده.", + work_team_add: "تیم جدید", + work_team_edit: "ویرایش تیم", + work_team_name: "نام تیم", + work_team_active: "فعال", + work_team_inactive: "غیرفعال", + work_members: "عضو", + work_team_delete_confirm: "تیم «{0}» حذف شود؟ کارهای قبلی نزد همان افراد باقی می‌ماند.", + + work_status_planned: "پلان‌شده", + work_status_in_progress: "در جریان", + work_status_done: "انجام شد", + work_status_blocked: "متوقف", + + work_task_ph: "مثلاً: قالب‌بندی منزل سوم", + work_location: "محل", + work_location_ph: "مثلاً: بلاک B، منزل سوم", + work_team: "تیم", + work_no_team: "بدون تیم (انفرادی)", + work_also_people: "افراد اضافی", + work_keep_assignees: "اگر تیم و افراد را دست نزنید، مسئولان همین کار بدون تغییر می‌مانند.", + + work_mine_title: "کار شما", + work_mine_hint: "همان چیزی که کارمند در اپ موبایل می‌بیند.", + work_today: "امروز", + work_next: "روز کاری بعد", + work_day_free: "برای این روز کاری به شما تعیین نشده.", + work_day_weekend: "این روز رخصتی هفته‌وار است.", + work_day_holiday: "این روز رخصتی رسمی است.", + work_with: "همراه", + work_start: "شروع کردم", + work_finish: "تمام شد", + dev_title: "دستگاه‌ها و لایسنس", + dev_no_access: "دسترسی به این بخش ندارید", + dev_saved: "ذخیره شد", + dev_license: "لایسنس", + dev_plan: "پلان", + dev_plan_free: "رایگان", + dev_plan_standard: "استندرد", + dev_plan_enterprise: "سازمانی", + dev_limit: "سقف دستگاه", + dev_status: "وضعیت", + dev_status_active: "فعال", + dev_status_suspended: "معلق", + dev_status_expired: "منقضی", + dev_expires: "تاریخ انقضا", + dev_expires_hint: "خالی = بدون انقضا", + dev_enforce: "اعمال محدودیت دستگاه", + dev_expires_never: "بدون انقضا", + dev_license_vendor_hint: "لایسنس شما را لینومیک صادر می‌کند. برای افزودن دستگاه، تمدید تاریخ انقضا یا تغییر پلان با ما تماس بگیرید — مشخصات تماس در بستهٔ تحویل شماست.", + dev_seats_used: "{0} از {1} دستگاه", + dev_registered: "دستگاه‌های ثبت‌شده", + dev_empty: "هنوز دستگاهی ثبت نشده", + dev_device: "دستگاه", + dev_unnamed: "بدون نام", + dev_type: "نوع", + dev_type_mobile: "موبایل", + dev_type_kiosk: "کیوسک", + dev_employee: "کارمند", + dev_last_seen: "آخرین اتصال", + dev_revoked: "لغو شده", + dev_revoke: "لغو", + dev_restore: "بازگردانی", + + kiosk_title: "برای ثبت حاضری اسکن کنید", + kiosk_hint: "اپ WorkTrack را باز کنید و «اسکن QR کیوسک» را بزنید.", + kiosk_rotates: "کود هر ۳۰ ثانیه تازه می‌شود", + kiosk_exit: "خروج از حالت کیوسک", + + kioskdev_title: "دستگاه‌های کیوسک", + kioskdev_hint: "برای هر تبلت یک حساب کیوسک بسازید تا بدون ورود مدیر، دائم روی صفحهٔ حاضری بماند.", + kioskdev_add: "ساخت حساب کیوسک", + kioskdev_label: "نام دستگاه (مثلاً کیوسک ورودی)", + kioskdev_empty: "هنوز دستگاه کیوسکی ساخته نشده", + kioskdev_created: "حساب کیوسک ساخته شد", + kioskdev_creds_hint: "این ایمیل و رمز را در مرورگر تبلت (همین آدرس) وارد کنید تا دائم روی صفحهٔ کیوسک بماند:", + kioskdev_reset: "رمز جدید", + kioskdev_reset_done: "رمز جدید ساخته شد", + + shift_title: "شیفت‌ها و روستر", + shift_defs: "تعریف شیفت‌ها", + shift_add: "شیفت جدید", + shift_edit: "ویرایش شیفت", + shift_name: "نام شیفت", + shift_code: "کود", + shift_start: "شروع", + shift_end: "پایان", + shift_break: "استراحت (دقیقه)", + shift_grace_in: "مهلت ورود (دقیقه)", + shift_grace_out: "مهلت خروج (دقیقه)", + shift_time: "ساعت", + shift_active: "فعال", + shift_inactive: "غیرفعال", + shift_night: "شیفت شب", + shift_24h: "۲۴ ساعته", + shift_hours: "{0} ساعت", + shift_empty: "هنوز شیفتی تعریف نشده — یک شیفت اضافه کنید", + shift_saved: "شیفت ذخیره شد", + + roster_title: "روستر روزانه", + roster_date: "تاریخ", + roster_assign: "تخصیص شیفت", + roster_pick_shift: "انتخاب شیفت", + roster_pick_emps: "کارمندان", + roster_from: "از تاریخ", + roster_to: "تا تاریخ (اختیاری)", + roster_submit: "ثبت روستر", + roster_assigned: "روستر برای {0} مورد ثبت شد", + roster_empty: "برای این روز روستری ثبت نشده", + roster_employee: "کارمند", + roster_shift_col: "شیفت", + + set_title: "تنظیمات شرکت", + set_features: "امکانات", + set_features_hint: "ماژول‌هایی که برای شرکت شما فعال باشد را انتخاب کنید. ماژول خاموش از منو پنهان می‌شود.", + set_policies: "سیاست‌های کاری", + set_profile: "مشخصات", + feat_shifts: "شیفت‌بندی", + feat_leave: "رخصتی", + feat_payroll: "معاش", + feat_regularization: "اصلاح حاضری", + feat_announcements: "اعلانات", + feat_geofencing: "محدودهٔ جغرافیایی (GPS)", + feat_qr: "کیوسک QR", + feat_face: "تشخیص چهره", + pol_daily_hours: "ساعات کاری روزانه", + pol_weekend: "روزهای تعطیل هفته", + pol_grace: "مهلت ناوقتی (دقیقه)", + pol_overtime: "محاسبهٔ اضافه‌کاری", + pol_currency: "واحد پول", + pol_timezone: "منطقهٔ زمانی", + set_save: "ذخیرهٔ تغییرات", + set_saving: "در حال ذخیره…", + set_saved: "تنظیمات ذخیره شد", + wd_sat: "شنبه", + wd_sun: "یکشنبه", + wd_mon: "دوشنبه", + wd_tue: "سه‌شنبه", + wd_wed: "چهارشنبه", + wd_thu: "پنجشنبه", + wd_fri: "جمعه", + + leave_title: "تاییدی رخصتی", + leave_employee: "کارمند", + leave_dates: "تاریخ‌ها", + leave_days: "روزها", + leave_reason: "دلیل", + leave_approve: "تایید", + leave_reject: "رد", + leave_empty: "درخواست رخصتی در انتظار نیست", + leave_approved: "درخواست تایید شد", + leave_rejected: "درخواست رد شد", + leave_reject_prompt: "دلیل رد را بنویسید:", + + att_status_present: "حاضر", + att_status_absent: "غیرحاضر", + att_status_half_day: "نیم روز", + att_status_leave: "رخصتی", + att_status_holiday: "رخصتی عمومی", + att_status_week_off: "رخصتی هفته", + att_status_pending: "در انتظار", + + common_retry: "تلاش دوباره", + common_loading: "در حال بارگذاری…", + common_error: "مشکلی پیش آمد", + common_days: "روز", + common_minutes: "دقیقه", + common_cancel: "لغو", + sup_issues: "مشکلات ثبت‌شده", + sup_issues_hint: "به‌جای ایمیل، مشکل را از همین‌جا بفرستید — نام شرکت، پلان و تعداد سیت خودکار همراهش می‌رود.", + sup_raise: "ثبت مشکل", + sup_subject: "موضوع", + sup_subject_ph: "اپ روی گوشی‌های قدیمی نصب نمی‌شود", + sup_detail: "توضیح", + sup_detail_ph: "چه اتفاقی افتاد، روی چند دستگاه، از کِی؟", + sup_send: "فرستادن", + sup_issue_sent: "ثبت شد. به‌زودی تماس می‌گیریم.", + sup_issue_failed: "فرستاده نشد. اگر فوری است زنگ بزنید.", + sup_status_open: "باز", + sup_status_waiting: "در انتظار", + sup_status_resolved: "حل شد", + sup_title: "پشتیبانی", + sup_intro: "ورک‌ترک محصول لینومیک است. برای پشتیبانی، تمدید لایسنس یا افزودن دستگاه با ما تماس بگیرید.", + sup_phone: "تلفن", + sup_email: "ایمیل", + sup_web: "وب‌سایت", + sup_company_id: "شناسهٔ شرکت شما", + sup_company_id_hint: "هنگام تماس این شناسه را بدهید؛ لایسنس به همین شناسه صادر می‌شود.", + sup_copy: "کپی", + sup_copied: "کپی شد", + comp_scope: "شمول", + comp_scope_all: "همهٔ کارمندان", + comp_scope_individual: "فقط کارمندان مشخص", + comp_scope_hint: "«همهٔ کارمندان» یعنی به‌صورت پیش‌فرض به همه تعلق می‌گیرد. «فقط کارمندان مشخص» یعنی به کسی تعلق نمی‌گیرد مگر در پروندهٔ خودش داده شود.", + empc_title: "عواید و کسرات این کارمند", + empc_hint: "این‌ها فقط روی فیش همین کارمند اثر می‌گذارند.", + empc_applies: "شامل شود", + empc_amount: "مبلغ ویژه", + empc_default: "پیش‌فرض", + empc_default_hint: "خالی = همان مبلغ عمومی", + empc_all_note: "به همه تعلق می‌گیرد", + empc_individual_note: "فقط با انتخاب", + empc_none: "هنوز هیچ عواید یا کسراتی برای شرکت تعریف نشده. اول در صفحهٔ معاش تعریف کنید.", + empc_saved: "ثبت شد", + empc_save_first: "اول کارمند را ذخیره کنید، بعد عواید و کسرات او را تعیین کنید.", + comp_title: "عواید و کسرات", + comp_hint: "هر چه غیر از معاش اساسی، مالیه و کسر غیرحاضری در فیش می‌آید از اینجا تعریف می‌شود — مثل کمک‌هزینهٔ ترانسپورت یا کسر قرضه.", + comp_add: "افزودن مورد", + comp_name: "نام", + comp_name_ph: "کمک‌هزینهٔ ترانسپورت", + comp_code: "کود", + comp_code_hint: "فقط حروف بزرگ انگلیسی، عدد و _ . در فیش نشان داده می‌شود.", + comp_type: "نوع", + comp_type_earning: "عواید", + comp_type_deduction: "کسرات", + comp_type_employer_cost: "هزینهٔ کارفرما", + comp_calc: "طرز محاسبه", + comp_calc_fixed: "مبلغ ثابت", + comp_calc_percent_of_basic: "فیصدی از معاش اساسی", + comp_calc_percent_of_gross: "فیصدی از ناخالص", + comp_calc_earning_hint: "برای عواید «فیصدی از ناخالص» وجود ندارد؛ ناخالص خودش از عواید ساخته می‌شود.", + comp_value_afn: "مبلغ (افغانی)", + comp_value_percent: "فیصدی", + comp_taxable: "مشمول مالیه", + comp_taxable_hint: "اگر خاموش باشد، این عواید در محاسبهٔ مالیهٔ معاش شامل نمی‌شود.", + comp_tax_exempt: "معاف", + comp_amount: "مبلغ", + comp_afn: "افغانی", + comp_of_basic: "از معاش اساسی", + comp_of_gross: "از ناخالص", + comp_status: "وضعیت", + comp_active: "فعال", + comp_inactive: "غیرفعال", + comp_edit: "ویرایش", + comp_activate: "فعال کردن", + comp_deactivate: "غیرفعال کردن", + comp_activated: "فعال شد", + comp_deactivated: "غیرفعال شد", + comp_saved: "ذخیره شد", + comp_empty: "هنوز عواید یا کسراتی تعریف نشده. فیش فقط معاش اساسی، مالیه و کسر غیرحاضری را نشان می‌دهد.", + comp_rerun_hint: "تغییر در این فهرست روی فیش‌های قبلی اثر نمی‌گذارد. برای اعمال، معاش آن ماه را دوباره اجرا کنید.", + comp_err_name: "نام را بنویسید.", + comp_err_code: "کود باید فقط حروف بزرگ انگلیسی، عدد و _ باشد (حداکثر ۲۴ حرف).", + comp_err_value: "مبلغ نباید منفی باشد.", + comp_err_percent: "فیصدی نمی‌تواند بیشتر از ۱۰۰ باشد.", + comp_err_duplicate: "کود {0} قبلاً استفاده شده است.", + common_yes: "بله", + common_no: "خیر", + common_close: "بستن", + common_save: "ذخیره", + common_saving: "در حال ذخیره…", + + nav_finance: "مالی", + feat_finance: "مالی و حسابداری", + fin_title: "مالی و حسابداری", + fin_tab_overview: "نمای کلی", + fin_tab_expenses: "هزینه‌ها", + fin_tab_ledger: "دفتر کل", + fin_net_profit: "سود خالص", + fin_income: "درآمد", + fin_expense: "هزینه", + fin_payroll_cost: "هزینهٔ معاش", + fin_expenses_approved: "هزینه‌های تأییدشده", + fin_expenses_pending: "در انتظار تأیید", + fin_position: "وضعیت مالی", + fin_assets: "دارایی‌ها", + fin_liabilities: "بدهی‌ها", + fin_net: "خالص", + fin_trend: "روند ماهانه", + fin_no_trend: "هنوز داده‌ای نیست", + fin_month: "ماه", + fin_add_expense: "ثبت هزینه", + fin_expenses_empty: "هنوز هزینه‌ای ثبت نشده است", + fin_date: "تاریخ", + fin_vendor: "تأمین‌کننده", + fin_category: "دسته", + fin_amount: "مبلغ", + fin_status: "وضعیت", + fin_description: "توضیح", + fin_cat_rent: "کرایه", + fin_cat_utilities: "خدمات (آب/برق)", + fin_cat_supplies: "لوازم دفتر", + fin_cat_travel: "سفر و حمل‌ونقل", + fin_cat_services: "خدمات", + fin_cat_other: "متفرقه", + fin_status_draft: "پیش‌نویس", + fin_status_approved: "تأییدشده", + fin_status_paid: "پرداخت‌شده", + fin_status_rejected: "ردشده", + fin_approve: "تأیید", + fin_reject: "رد", + fin_mark_paid: "پرداخت شد", + fin_expense_added: "هزینه ثبت شد", + fin_expense_updated: "هزینه به‌روزرسانی شد", + fin_form_invalid: "لطفاً فیلدها را درست پر کنید", + fin_add_journal: "ثبت سند", + fin_trial_balance: "تراز آزمایشی", + fin_ledger_empty: "هنوز ثبتی در دفتر کل نیست", + fin_account: "حساب", + fin_debit: "بدهکار", + fin_credit: "بستانکار", + fin_total: "مجموع", + fin_journal: "اسناد روزنامه", + fin_journal_empty: "هنوز سندی ثبت نشده است", + fin_memo: "شرح", + fin_debit_account: "حساب بدهکار", + fin_credit_account: "حساب بستانکار", + fin_journal_invalid: "سند نامعتبر است (حساب‌ها و مبلغ را بررسی کنید)", + fin_journal_added: "سند ثبت شد", +}; + +const ps: Dict = { + app_title: "د WorkTrack مدیر پورتال", + tagline: "د افغانستان لپاره د کاري ځواک هوښیار مدیریت", + brand_tagline: "د بشري منابعو سیسټم", + + nav_menu: "مینو", + theme_to_dark: "تیاره حالت", + theme_to_light: "روښانه حالت", + nav_dashboard: "ډشبورډ", + nav_employees: "کارکوونکي", + nav_attendance: "حاضري", + nav_leave: "رخصتۍ", + nav_payroll: "معاش", + nav_logout: "وتل", + + pay_title: "معاش", + pay_run: "د معاش اجرا", + pay_running: "محاسبه کېږي…", + pay_period: "دوره", + pay_year: "کال", + pay_month: "میاشت", + pay_status: "حالت", + pay_employees: "کارکوونکي", + pay_total_gross: "ټول ناخالص", + pay_total_net: "ټول خالص", + pay_runs_empty: "تر اوسه معاش نه دی اجرا شوی. یوه میاشت وټاکئ او «د معاش اجرا» کېکاږئ.", + pay_run_done: "معاش د {0} کارکوونکو لپاره محاسبه شو", + pay_exited_title: "{0} تنه چې «وتلي» دي پدې میاشت کې کار کړی", + pay_exited_body: "وتلي کارکوونکي ته فیش نه جوړیږي. که دې میاشتې معاش ورکول کیږي، بیا یې «فعال» کړئ، معاش بیا محاسبه کړئ، بیا یې «وتلی» وټاکئ.", + pay_skipped_title: "{0} کارکوونکي پدې محاسبه کې نه دي راغلي", + pay_skipped_body: "د دوی لپاره بنسټیزه معاش نه ده ثبت شوې. د کارکوونکو په پاڼه کې یې معاش ثبت کړئ او بیا محاسبه وکړئ.", + pay_provisional: "لنډمهاله", + pay_provisional_hint: "دا میاشت لا نه ده پای ته رسېدلې. ارقام یوازې تېرې ورځې رانغاړي؛ د میاشتې تر پایه وروسته یې بیا چل کړئ.", + pay_view: "کتنه", + pay_employee: "کارکوونکی", + pay_gross: "ناخالص", + pay_deductions: "کسرات", + pay_net: "خالص", + pay_worked_days: "د کار ورځې", + pay_tax: "مالیه", + pay_ctc: "د شرکت ټول لګښت", + pay_total_tax: "ټوله مالیه", + pay_back_to_runs: "دورو ته بیرته", + + login_email: "کاري برېښنالیک", + login_password: "پټنوم", + login_submit: "ننوتل", + login_error: "برېښنالیک یا پټنوم سم نه دی", + login_no_access: "دا حساب پورټال ته لاسرسی نه لري", + login_signing_in: "ننوتل کېږي…", + login_show_password: "رمز ښکاره کول", + login_hide_password: "رمز پټول", + login_welcome: "ښه راغلاست", + auth_point_1: "د GPS، QR او کیوسک له لارې حاضري", + auth_point_2: "معاش، رخصتي او د حاضرۍ سمون", + auth_point_3: "لمریز کلیز او ژوندي راپورونه", + + signup_title: "د شرکت ثبت", + signup_sub: "د خپل شرکت کاري ځای په څو ثانیو کې جوړ کړئ", + signup_company: "د شرکت نوم", + signup_admin_first: "د مدیر نوم", + signup_admin_last: "د مدیر تخلص", + signup_submit: "د کاري ځای جوړول", + signup_creating: "جوړېږي…", + signup_no_account: "نوی شرکت؟ ثبت نام وکړئ", + signup_have_account: "حساب لرئ؟ ننوځئ", + signup_email_exists: "دا برېښنالیک له مخکې ثبت شوی", + signup_password_hint: "لږ تر لږه ۸ توري", + verify_title: "خپل ایمیل تایید کړئ", + verify_sent: "د تایید لینک {0} ته ولېږل شو.", + verify_hint: "پر لینک کلیک وکړئ او بیا ننوځئ. که په انباکس کې نه و، سپام پوښه وګورئ.", + verify_resend: "لینک بیا ولېږئ", + verify_resent: "لینک بیا ولېږل شو.", + verify_back: "ننوتلو ته بېرته", + + dash_title: "د نن ورځې لنډیز", + dash_active_employees: "فعال کارکوونکي", + dash_present: "حاضر", + dash_absent: "غیرحاضر", + dash_on_leave: "په رخصتۍ کې", + dash_late: "ناوخته", + dash_half_day: "نیمه ورځ", + dash_pending_leave: "په تمه رخصتۍ", + dash_attendance_rate: "د حاضرۍ کچه", + dash_trend: "د حاضرۍ روند (۷ ورځې)", + + emp_title: "کارکوونکي", + emp_add: "کارکوونکی ورزیاتول", + emp_edit: "سمون", + emp_edit_title: "د کارمند سمون", + emp_updated: "کارمند تازه شو", + emp_login: "د ننوتلو حساب", + emp_reset_password: "رمز بیا جوړول", + emp_set_password: "رمز ټاکل", + emp_set_password_ph: "دلخواه رمز (خالي = تصادفي، لږترلږه ۸ توري)", + emp_code: "کوډ", + emp_code_auto: "اتوماتیک", + emp_role_unchanged: "بې بدلونه", + adv_title: "مخکینۍ ورکړې", + adv_sub: "هغه پیسې چې له معاش دمخه کارمند ته ورکړل شوي او له معاش نه کمېږي", + adv_add: "مخکینۍ ورکړه ثبت کړئ", + adv_empty: "هېڅ مخکینۍ ورکړه نه ده ثبت شوې", + adv_employee: "کارمند", + adv_pick_employee: "کارمند وټاکئ", + adv_principal: "اندازه", + adv_instalment: "میاشتنۍ قسط", + adv_instalment_hint: "تش یې پرېږدئ چې په راتلونکي معاش کې یوځل کم شي", + adv_in_full: "یوځل", + adv_outstanding: "پاتې", + adv_issued: "نېټه", + adv_status: "حالت", + adv_status_outstanding: "پاتې", + adv_status_settled: "تصفیه شوه", + adv_status_cancelled: "لغوه شوه", + adv_cancel: "لغوه", + adv_cancel_confirm: "د {0} مخکینۍ ورکړه لغوه شي؟ دا بېرته نه راګرځي.", + adv_cancelled: "مخکینۍ ورکړه لغوه شوه", + adv_saved: "مخکینۍ ورکړه ثبت شوه", + adv_total_owed: "ټول پاتې: {0}", + adv_note: "کمول یې په راتلونکي معاش کې کېږي، له مالیې وروسته. که د میاشتې معاش بس نه وي، هومره چې ممکن وي کمېږي او پاتې یې بلې میاشتې ته پاتې کېږي — معاش هېڅکله منفي نه کېږي.", + adv_note_field: "یادښت", + doc_title: "اسناد", + doc_empty: "هېڅ سند نه دی ثبت شوی", + doc_add: "سند زیات کړئ", + doc_delete: "ړنګول", + doc_type: "د سند ډول", + doc_number: "شمېره", + doc_expires_on: "د پای نېټه", + doc_expires_hint: "تش یې پرېږدئ که سند پای نلري (لکه تذکره)", + doc_no_expiry: "بې پایه", + doc_expires: "تر {0}", + doc_expired: "پای ته رسېدلی — {0}", + doc_type_tazkira: "تذکره", + doc_type_contract: "د کار قرارداد", + doc_type_work_permit: "د کار جواز", + doc_type_health_certificate: "روغتیایي سند", + doc_type_licence: "جواز", + doc_type_other: "نور", + notif_title: "خبرتیاوې", + notif_empty: "هېڅ خبرتیا نشته", + notif_mark_all: "ټول لوستل شوي", + notif_unread: "{0} نالوستې خبرتیا", + piece_title: "د ټوټې کار", + piece_sub: "د هر کارمند د بشپړو شویو ټوټو شمېر — د هغو لپاره چې د ټوټې په حساب کار کوي، همدا یې مزد دی", + piece_add: "شمېر ثبت کړئ", + piece_empty: "هېڅ شمېر نه دی ثبت شوی", + piece_employee: "کارمند", + piece_pick_employee: "کارمند وټاکئ", + piece_date: "نېټه", + piece_quantity: "شمېر", + piece_note: "یادښت", + piece_delete: "ړنګول", + piece_delete_confirm: "د {0} دا ثبت ړنګ شي؟", + piece_deleted: "ړنګ شو", + piece_saved: "ثبت شو", + piece_err_required: "کارمند او شمېر اړین دي", + emp_pay_model: "د تادیې ډول", + pay_model_monthly: "میاشتنی", + pay_model_daily: "ورځنی", + pay_model_piece: "د ټوټې په حساب", + pay_model_hint_monthly: "ثابت میاشتنی معاش؛ د غیرحاضرۍ ورځې ترې کمېږي.", + pay_model_hint_daily: "د هرې کاري ورځې مزد. کومه ورځ چې نه وي راغلی، تادیه نه کېږي — او جلا هم نه کمېږي.", + pay_model_hint_piece: "د هرې ټوټې مزد. شمېر یې د معاش په پاڼه کې ثبت کړئ.", + emp_rate_daily: "ورځنی مزد ({0})", + emp_rate_piece: "د یوې ټوټې مزد ({0})", + pay_rate_monthly: "میاشتنی معاش", + pay_rate_daily: "ورځنی مزد", + pay_rate_piece: "د ټوټې مزد", + sheet_open: "د تادیې پاڼه", + sheet_csv: "د اکسل وتنه", + sheet_print: "چاپ", + sheet_title: "د معاش د تادیې پاڼه", + sheet_period: "د {0} دوره", + sheet_row: "ش", + sheet_code: "کوډ", + sheet_name: "نوم او تخلص", + sheet_gross: "ناخالص", + sheet_deductions: "کمښتونه", + sheet_net: "د تادیې وړ", + sheet_signature: "لاسلیک / د ګوتې نښه", + sheet_total: "ټول — {0} تنه", + sheet_paid_by: "تادیه کوونکی:", + sheet_approved_by: "تصدیق کوونکی:", + adv_err_required: "کارمند او اندازه اړین دي", + adv_err_instalment: "قسط باید مثبت عدد وي", + adv_err_instalment_big: "قسط له خپلې اندازې زیات دی", + signup_business_type: "د کاروبار ډول", + signup_business_type_skip: "وټاکئ (اختیاري)", + signup_business_type_hint: "یوازې لومړني تنظیمات ټاکي؛ ټول یې وروسته د بدلون وړ دي.", + biz_office: "اداري دفتر", + biz_construction: "ودانیزه شرکت", + biz_retail: "پلورنځی", + biz_tailoring: "د خیاطۍ کارګاه", + biz_warehouse: "ګودام او لوژستیک", + biz_security: "امنیتي شرکت", + biz_restaurant: "رستوران او کافې", + biz_clinic: "کلینیک او درملتون", + biz_school: "ښوونځی او کورس", + biz_ngo: "مؤسسه او پروژه", + biz_exchange: "صرافي", + biz_transport: "ترانسپورټي شرکت", + biz_production: "تولیدي او نانوايي", + biz_agriculture: "کرنه او مالداري", + biz_hospitality: "هوټل او مېلمستون", + emp_name: "نوم", + emp_email: "برېښنالیک", + emp_branch: "څانګه", + emp_status: "حالت", + emp_face: "مخ", + emp_face_enrolled: "ثبت شوی", + emp_face_not_enrolled: "نه دی ثبت شوی", + emp_face_reset: "بیا تنظیمول", + emp_face_reset_done: "د مخ ثبت پاک شو", + emp_basic_salary: "میاشتنی اساسي معاش ({0})", + emp_basic_salary_ph: "لکه ۳۰۰۰۰", + emp_basic_salary_hint: "د اساسي معاش پرته، دې کارکوونکي ته د معاش په اجرا کې فیش نه ورکول کیږي.", + emp_type: "د دندې ډول", + emp_join_date: "د شاملېدو نېټه", + emp_phone: "تلیفون", + emp_save: "خوندي کول", + emp_cancel: "لغوه", + emp_created: "کارکوونکی ورزیات شو", + emp_search: "د نوم یا کوډ لټون…", + emp_load_more: "نور بار کړئ", + emp_empty: "کارکوونکی ونه موندل شو", + emp_role: "دنده", + emp_create_login: "د موبایل اپ لپاره د ننوتلو حساب جوړ کړئ", + emp_password_optional: "پټنوم (تش = اتومات لنډمهاله پټنوم)", + emp_credentials_title: "د کارکوونکي حساب جوړ شو", + emp_credentials_hint: "دا معلومات کارکوونکي ته ورکړئ چې موبایل اپ ته ننوځي:", + emp_credentials_email: "برېښنالیک", + emp_credentials_password: "لنډمهاله پټنوم", + emp_credentials_copy: "کاپي", + emp_credentials_copied: "کاپي شو", + emp_credentials_done: "پای", + role_employee: "کارکوونکی", + role_team_lead: "د ډلې مشر", + role_branch_manager: "د څانګې مدیر", + role_hr_admin: "د بشري منابعو مدیر", + role_payroll_admin: "د معاش مدیر", + role_auditor: "پلټونکی", + + status_active: "فعال", + status_on_leave: "په رخصتۍ کې", + status_suspended: "معطل", + status_exited: "وتلی", + + type_full_time: "بشپړ وخت", + type_part_time: "نیم وخت", + type_contract: "قراردادي", + type_intern: "کارآموز", + + att_title: "د حاضرۍ څارنه", + att_date: "نېټه", + att_present: "حاضر", + att_absent: "غیرحاضر", + att_first_in: "لومړی ورتګ", + att_worked: "کار", + att_late_by: "{0} دقیقې ناوخته", + att_empty: "د دې ورځې لپاره معلومات نشته", + att_needs_review: "بیاکتنې ته اړتیا", + att_needs_review_hint: "حاضري د مخ له تصدیق پرته ثبت شوې ده", + att_face_verified: "مخ تصدیق شو", + att_rejected: "ثبت نه شو", + att_rejected_hint: "{0} — په {1} بجو هڅه وشوه خو ونه شمېرل شوه", + att_rejected_count: "{0} ردې شوې پانچ", + att_reason_geofence: "د کاري ساحې څخه بهر", + att_reason_time_skew: "د وسیلې ساعت سم نه دی", + att_reason_too_old: "ډېر ناوخته واستول شو", + att_reason_travel: "ناشونی سفر", + att_reason_kiosk: "د کیوسک کوډ ناسم دی", + att_reason_unknown: "نامعلوم لامل", + att_inactive: "غیرفعال", + att_company_time: "د شرکت په وخت", + att_view_selfie: "د ننوتلو انځور وګورئ", + att_view_daily: "ورځنی", + att_view_weekly: "اونیز", + att_week_total: "د اونۍ ټولټال", + + reg_title: "د حاضرۍ د سمون غوښتنې", + reg_pending: "په تمه سمونونه", + reg_date: "نېټه", + reg_requested_in: "وړاندیز شوی ورتګ", + reg_requested_out: "وړاندیز شوی وتل", + reg_reason: "دلیل", + reg_approve: "تایید", + reg_reject: "رد", + reg_empty: "په تمه د حاضرۍ د سمون غوښتنه نشته", + reg_approved: "سمون تایید شو", + reg_rejected: "سمون رد شو", + reg_reject_prompt: "د رد دلیل ولیکئ:", + + nav_shifts: "شیفټونه", + nav_kiosk: "کیوسک", + nav_settings: "تنظیمات", + dz_title: "د شرکت حساب تړل", + dz_hint: "د شرکت حساب او ټول معلومات یې ړنګیږي. {0} ورځې فرصت لرئ چې فکر بدل کړئ؛ وروسته بیرته نه راځي.", + dz_start: "د شرکت حساب تړل", + dz_confirm_body: "په دې سره د تل لپاره له لاسه ځي:", + dz_loses_attendance: "د حاضرۍ او رخصتۍ ټول ریکارډونه", + dz_loses_payroll: "د معاش ټولې اجراګانې، فیشونه او لویه دفتره", + dz_loses_logins: "د ټولو کارکوونکو او کیوسکونو د ننوتلو حسابونه", + dz_type_name: "د تایید لپاره د شرکت نوم په دقت ولیکئ: {0}", + dz_reason: "لامل (اختیاري)", + dz_reason_ph: "ستاسو د خپلو ریکارډونو لپاره", + dz_confirm: "هو، حساب وتړه", + dz_scheduled: "د حساب تړل مهالویش شو", + dz_cancelled: "لغوه شو — حساب بیا فعال دی", + dz_scheduled_title: "دا حساب د تړلو لپاره مهالویش شوی", + dz_scheduled_body: "معلومات به په {0} ړنګ شي. تر هغې ورځې هر وخت لغوه کولی شئ او هر څه بشپړ بیرته راځي.", + dz_cancel: "لغوه او بیا فعالول", + att_weekend: "نن د اونۍ رخصتي ده", + att_holiday: "نن رسمي رخصتي ده", + att_closed_hint: "نن څوک حاضري نه لري او دا ورځ غیرحاضري نه ګڼل کیږي.", + hol_title: "کاري جنتري", + hol_hint: "هغه ورځې چې شرکت تړلی وي. معاش دا ورځې او د اونۍ رخصتي ورځې غیرحاضري نه ګڼي.", + hol_year: "کال", + hol_seed: "د کال ثابتې رخصتۍ جوړول", + hol_seeded: "{0} رخصتي اضافه شوه", + hol_seed_none: "د اضافه کولو لپاره څه نه وو", + hol_date: "نېټه", + hol_name: "مناسبت", + hol_name_ph: "لکه کوچنی اختر", + hol_paid: "له معاش سره", + hol_paid_yes: "له معاش سره", + hol_paid_no: "بې معاشه", + hol_add: "زیاتول", + hol_remove: "ړنګول", + hol_saved: "خوندي شو", + hol_empty: "د دې کال لپاره هیڅ رخصتي نه ده ثبت شوې", + hol_generated: "اتومات", + hol_lunar_note: "نوروز او د خپلواکۍ ورځ په لمریز کال کې ثابتې دي او اتومات جوړیږي. کوچنی اختر، لوی اختر، عاشورا او میلادالنبي قمري دي او نېټه یې د میاشتې په لیدو اعلانیږي — هغه هر کال پخپله اضافه کړئ.", + nav_devices: "وسایل او جواز", + nav_work: "کار او پروژه", + + work_title: "کار او پروژه", + work_intro: "هر کارکوونکی باید پوه شي چې نن او سبا په کومه برخه کار کوي. دلته یې ټاکئ.", + work_tab_board: "د نن کار", + work_tab_plan: "پلان", + work_tab_projects: "پروژې", + work_tab_teams: "ټیمونه", + work_tab_mine: "زما کار", + + work_board_title: "څوک په کوم کار دی", + work_board_empty: "د دې ورځې لپاره لا کار نه دی ټاکل شوی.", + work_prev_day: "تېره ورځ", + work_next_day: "راتلونکې ورځ", + work_date: "نېټه", + work_assign: "کار ټاکل", + work_person: "کارکوونکی", + work_task: "کار", + work_items: "کار", + + work_plan_title: "د کار پلان", + work_plan_empty: "په دې موده کې کار نه دی ټاکل شوی.", + work_from: "له نېټې", + work_to_optional: "تر نېټې (اختیاري)", + work_span: "موده", + work_span_day: "یوه ورځ", + work_span_week: "یوه اونۍ", + work_span_month: "یوه میاشت", + work_project: "پروژه", + work_all_projects: "ټولې پروژې", + work_when: "وخت", + work_who: "مسئول", + work_status: "حالت", + work_edit: "سمول", + work_delete: "ړنګول", + work_delete_confirm: "«{0}» ړنګ شي؟", + work_deleted: "ړنګ شو", + work_saved: "ثبت شو", + + work_projects_title: "پروژې", + work_projects_hint: "د شرکت قراردادونه او کاري ساحې. کار پروژې پورې تړلی دی.", + work_projects_empty: "لا پروژه نه ده ثبت شوې.", + work_project_add: "نوې پروژه", + work_project_edit: "پروژه سمول", + work_project_name: "د پروژې نوم", + work_code: "کوډ", + work_detail: "تفصیل", + work_choose_project: "پروژه وټاکئ", + work_pstatus_planned: "پلان شوې", + work_pstatus_active: "روانه", + work_pstatus_paused: "درېدلې", + work_pstatus_done: "بشپړه", + + work_teams_title: "کاري ټیمونه", + work_teams_hint: "ټیم د کارکوونکو ډله ده، څانګه نه. ټیمي کار ټولو غړو ته رسېږي.", + work_teams_empty: "لا ټیم نه دی جوړ شوی.", + work_team_add: "نوی ټیم", + work_team_edit: "ټیم سمول", + work_team_name: "د ټیم نوم", + work_team_active: "فعال", + work_team_inactive: "غیرفعال", + work_members: "غړي", + work_team_delete_confirm: "«{0}» ټیم ړنګ شي؟ پخواني کارونه به همدغو کسانو سره پاتې شي.", + + work_status_planned: "پلان شوی", + work_status_in_progress: "روان", + work_status_done: "ترسره شو", + work_status_blocked: "درېدلی", + + work_task_ph: "بېلګه: د دریم پوړ قالب بندي", + work_location: "ځای", + work_location_ph: "بېلګه: بلاک B، دریم پوړ", + work_team: "ټیم", + work_no_team: "بې ټیمه (انفرادي)", + work_also_people: "اضافي کسان", + work_keep_assignees: "که ټیم او کسان لاس ونه وهئ، د دې کار مسئولان بې بدلونه پاتې کېږي.", + + work_mine_title: "ستاسو کار", + work_mine_hint: "هماغه څه چې کارکوونکی یې په موبایل اپ کې ویني.", + work_today: "نن", + work_next: "راتلونکې کاري ورځ", + work_day_free: "د دې ورځې لپاره تاسو ته کار نه دی ټاکل شوی.", + work_day_weekend: "دا ورځ د اونۍ رخصتي ده.", + work_day_holiday: "دا ورځ رسمي رخصتي ده.", + work_with: "ملګري", + work_start: "پیل مې کړ", + work_finish: "بشپړ شو", + dev_title: "وسایل او جواز", + dev_no_access: "دې برخې ته لاسرسی نه لرئ", + dev_saved: "خوندي شو", + dev_license: "جواز", + dev_plan: "پلان", + dev_plan_free: "وړیا", + dev_plan_standard: "معیاري", + dev_plan_enterprise: "سازماني", + dev_limit: "د وسایلو حد", + dev_status: "حالت", + dev_status_active: "فعال", + dev_status_suspended: "ځنډول شوی", + dev_status_expired: "پای ته رسیدلی", + dev_expires: "د پای نېټه", + dev_expires_hint: "تش = بې پایه", + dev_enforce: "د وسایلو حد پلي کول", + dev_expires_never: "پر تل", + dev_license_vendor_hint: "ستاسو جواز لینومیک ورکوي. د نورو وسایلو زیاتولو، د پای نېټې اوږدولو یا د پلان بدلولو لپاره زموږ سره اړیکه ونیسئ — د اړیکې معلومات ستاسو د سپارلو بسته کې دي.", + dev_seats_used: "{0} له {1} وسایلو", + dev_registered: "ثبت شوي وسایل", + dev_empty: "تر اوسه هیڅ وسیله نه ده ثبت شوې", + dev_device: "وسیله", + dev_unnamed: "بې نومه", + dev_type: "ډول", + dev_type_mobile: "موبایل", + dev_type_kiosk: "کیوسک", + dev_employee: "کارکوونکی", + dev_last_seen: "وروستی اتصال", + dev_revoked: "لغوه شوی", + dev_revoke: "لغوه", + dev_restore: "بیرته راوستل", + + kiosk_title: "د حاضرۍ لپاره سکن کړئ", + kiosk_hint: "د WorkTrack اپ پرانیزئ او «د کیوسک QR سکن» کېکاږئ.", + kiosk_rotates: "کوډ هرې ۳۰ ثانیې تازه کېږي", + kiosk_exit: "د کیوسک حالت پرېښودل", + + kioskdev_title: "د کیوسک وسایل", + kioskdev_hint: "د هر ټابلیټ لپاره د کیوسک حساب جوړ کړئ چې د مدیر له ننوتلو پرته تل د حاضرۍ پر پاڼه پاتې شي.", + kioskdev_add: "د کیوسک حساب جوړول", + kioskdev_label: "د وسیلې نوم (لکه د ننوتلو کیوسک)", + kioskdev_empty: "تر اوسه د کیوسک وسیله نه ده جوړه شوې", + kioskdev_created: "د کیوسک حساب جوړ شو", + kioskdev_creds_hint: "دا برېښنالیک او پټنوم د ټابلیټ په براوزر (همدا پته) کې دننه کړئ چې تل د کیوسک پر پاڼه پاتې شي:", + kioskdev_reset: "نوی پټنوم", + kioskdev_reset_done: "نوی پټنوم جوړ شو", + + shift_title: "شیفټونه او روستر", + shift_defs: "د شیفټونو تعریف", + shift_add: "نوی شیفټ", + shift_edit: "د شیفټ سمون", + shift_name: "د شیفټ نوم", + shift_code: "کوډ", + shift_start: "پیل", + shift_end: "پای", + shift_break: "استراحت (دقیقې)", + shift_grace_in: "د ورتګ مهلت (دقیقې)", + shift_grace_out: "د وتلو مهلت (دقیقې)", + shift_time: "وخت", + shift_active: "فعال", + shift_inactive: "غیرفعال", + shift_night: "د شپې شیفټ", + shift_24h: "۲۴ ساعته", + shift_hours: "{0} ساعته", + shift_empty: "تر اوسه شیفټ نه دی تعریف شوی — یو شیفټ ورزیات کړئ", + shift_saved: "شیفټ خوندي شو", + + roster_title: "ورځنی روستر", + roster_date: "نېټه", + roster_assign: "د شیفټ ټاکل", + roster_pick_shift: "شیفټ وټاکئ", + roster_pick_emps: "کارکوونکي", + roster_from: "له نېټې", + roster_to: "تر نېټې (اختیاري)", + roster_submit: "روستر ثبت کړئ", + roster_assigned: "روستر د {0} مواردو لپاره ثبت شو", + roster_empty: "د دې ورځې لپاره روستر نشته", + roster_employee: "کارکوونکی", + roster_shift_col: "شیفټ", + + set_title: "د شرکت تنظیمات", + set_features: "امکانات", + set_features_hint: "هغه ماژولونه وټاکئ چې ستاسو د شرکت لپاره فعال وي. بند ماژول له مینو پټېږي.", + set_policies: "کاري تګلارې", + set_profile: "مشخصات", + feat_shifts: "شیفټ بندي", + feat_leave: "رخصتي", + feat_payroll: "معاش", + feat_regularization: "د حاضرۍ سمون", + feat_announcements: "اعلانونه", + feat_geofencing: "جغرافیايي ساحه (GPS)", + feat_qr: "د QR کیوسک", + feat_face: "د مخ پېژندنه", + pol_daily_hours: "ورځني کاري ساعتونه", + pol_weekend: "د اونۍ رخصتي ورځې", + pol_grace: "د ناوختۍ مهلت (دقیقې)", + pol_overtime: "د اضافه کار محاسبه", + pol_currency: "د پیسو واحد", + pol_timezone: "د وخت ساحه", + set_save: "بدلونونه خوندي کړئ", + set_saving: "خوندي کېږي…", + set_saved: "تنظیمات خوندي شول", + wd_sat: "شنبه", + wd_sun: "یکشنبه", + wd_mon: "دوشنبه", + wd_tue: "سه‌شنبه", + wd_wed: "چهارشنبه", + wd_thu: "پنجشنبه", + wd_fri: "جمعه", + + leave_title: "د رخصتۍ تایید", + leave_employee: "کارکوونکی", + leave_dates: "نېټې", + leave_days: "ورځې", + leave_reason: "دلیل", + leave_approve: "تایید", + leave_reject: "رد", + leave_empty: "په تمه د رخصتۍ غوښتنه نشته", + leave_approved: "غوښتنه تایید شوه", + leave_rejected: "غوښتنه رد شوه", + leave_reject_prompt: "د رد دلیل ولیکئ:", + + att_status_present: "حاضر", + att_status_absent: "غیرحاضر", + att_status_half_day: "نیمه ورځ", + att_status_leave: "رخصتي", + att_status_holiday: "عمومي رخصتي", + att_status_week_off: "اونیزه رخصتي", + att_status_pending: "په تمه", + + common_retry: "بیا هڅه", + common_loading: "بارېږي…", + common_error: "ستونزه رامنځته شوه", + common_days: "ورځې", + common_minutes: "دقیقې", + common_cancel: "لغوه", + sup_issues: "ثبت شوې ستونزې", + sup_issues_hint: "د بریښنالیک پر ځای ستونزه له همدې ځایه ولېږئ — د شرکت نوم، پلان او د ځایونو شمېر پخپله ورسره ځي.", + sup_raise: "ستونزه ثبت کړئ", + sup_subject: "موضوع", + sup_subject_ph: "اپ په زړو موبایلونو نه نصبیږي", + sup_detail: "تشریح", + sup_detail_ph: "څه پېښ شول، په څو وسیلو، له کله؟", + sup_send: "لېږل", + sup_issue_sent: "ثبت شو. ژر به اړیکه ونیسو.", + sup_issue_failed: "ونه لېږل شو. که بیړني وي، زنګ ووهئ.", + sup_status_open: "خلاص", + sup_status_waiting: "په تمه", + sup_status_resolved: "حل شو", + sup_title: "ملاتړ", + sup_intro: "ورک‌ټرک د لینومیک محصول دی. د ملاتړ، د جواز نوي کولو یا د نورو وسایلو زیاتولو لپاره زموږ سره اړیکه ونیسئ.", + sup_phone: "ټلیفون", + sup_email: "بریښنالیک", + sup_web: "ویب‌پاڼه", + sup_company_id: "ستاسو د شرکت پېژندنه", + sup_company_id_hint: "د اړیکې پر مهال دا پېژندنه ورکړئ؛ جواز پر همدې پېژندنه صادریږي.", + sup_copy: "کاپي", + sup_copied: "کاپي شو", + comp_scope: "شمول", + comp_scope_all: "ټول کارکوونکي", + comp_scope_individual: "یوازې ټاکل شوي کارکوونکي", + comp_scope_hint: "«ټول کارکوونکي» یعنې په اوتومات ډول ټولو ته رسیږي. «یوازې ټاکل شوي» یعنې هیچا ته نه رسیږي تر څو چې د هغه په دوسیه کې ورنه کړل شي.", + empc_title: "د دې کارکوونکي ګټې او کسرونه", + empc_hint: "دا یوازې د همدې کارکوونکي پر فیش اغېز کوي.", + empc_applies: "شامل شي", + empc_amount: "ځانګړی مبلغ", + empc_default: "تلواله", + empc_default_hint: "تش = هماغه عمومي مبلغ", + empc_all_note: "ټولو ته رسیږي", + empc_individual_note: "یوازې په ټاکنه", + empc_none: "تر اوسه د شرکت لپاره هېڅ ګټه یا کسر نه دی تعریف شوی. لومړی یې د معاش په پاڼه کې تعریف کړئ.", + empc_saved: "ثبت شو", + empc_save_first: "لومړی کارکوونکی خوندي کړئ، بیا یې ګټې او کسرونه وټاکئ.", + comp_title: "ګټې او کسرونه", + comp_hint: "هر څه چې له بنسټیز معاش، مالیې او د غیرحاضرۍ کسر پرته په فیش کې راځي، له همدې ځایه تعریفیږي — لکه د ترانسپورت مرسته یا د پور کسر.", + comp_add: "نوی توکی", + comp_name: "نوم", + comp_name_ph: "د ترانسپورت مرسته", + comp_code: "کوډ", + comp_code_hint: "یوازې لوی انګلیسي توري، شمېرې او _ . په فیش کې ښکاري.", + comp_type: "ډول", + comp_type_earning: "ګټې", + comp_type_deduction: "کسرونه", + comp_type_employer_cost: "د کارفرما لګښت", + comp_calc: "د محاسبې طریقه", + comp_calc_fixed: "ثابت مبلغ", + comp_calc_percent_of_basic: "د بنسټیز معاش سلنه", + comp_calc_percent_of_gross: "د ناخالصو سلنه", + comp_calc_earning_hint: "د ګټو لپاره «د ناخالصو سلنه» نشته؛ ناخالص پخپله له ګټو جوړیږي.", + comp_value_afn: "مبلغ (افغانۍ)", + comp_value_percent: "سلنه", + comp_taxable: "د مالیې تابع", + comp_taxable_hint: "که مړ وي، دا ګټه د معاش د مالیې په محاسبه کې نه شاملیږي.", + comp_tax_exempt: "معاف", + comp_amount: "مبلغ", + comp_afn: "افغانۍ", + comp_of_basic: "د بنسټیز معاش", + comp_of_gross: "د ناخالصو", + comp_status: "حالت", + comp_active: "فعال", + comp_inactive: "غیرفعال", + comp_edit: "سمون", + comp_activate: "فعالول", + comp_deactivate: "غیرفعالول", + comp_activated: "فعال شو", + comp_deactivated: "غیرفعال شو", + comp_saved: "خوندي شو", + comp_empty: "تر اوسه هېڅ ګټه یا کسر نه دی تعریف شوی. فیش یوازې بنسټیز معاش، مالیه او د غیرحاضرۍ کسر ښیي.", + comp_rerun_hint: "پدې لړ کې بدلون پخوانیو فیشونو باندې اغېز نه کوي. د تطبیق لپاره د هغې میاشتې معاش بیا چل کړئ.", + comp_err_name: "نوم ولیکئ.", + comp_err_code: "کوډ باید یوازې لوی انګلیسي توري، شمېرې او _ وي (تر ۲۴ تورو).", + comp_err_value: "مبلغ نه شي کولی منفي وي.", + comp_err_percent: "سلنه تر ۱۰۰ زیاته نه شي کېدای.", + comp_err_duplicate: "کوډ {0} مخکې کارول شوی دی.", + common_yes: "هو", + common_no: "نه", + common_close: "تړل", + common_save: "خوندي کول", + common_saving: "په خوندي کولو کې…", + + nav_finance: "مالي", + feat_finance: "مالي او حساب‌داري", + fin_title: "مالي او حساب‌داري", + fin_tab_overview: "لنډه کتنه", + fin_tab_expenses: "لګښتونه", + fin_tab_ledger: "لوی دفتر", + fin_net_profit: "خالص ګټه", + fin_income: "عاید", + fin_expense: "لګښت", + fin_payroll_cost: "د معاش لګښت", + fin_expenses_approved: "تصویب شوي لګښتونه", + fin_expenses_pending: "د تصویب په تمه", + fin_position: "مالي حالت", + fin_assets: "شتمنۍ", + fin_liabilities: "پورونه", + fin_net: "خالص", + fin_trend: "میاشتنی روند", + fin_no_trend: "تر اوسه معلومات نشته", + fin_month: "میاشت", + fin_add_expense: "لګښت ثبتول", + fin_expenses_empty: "تر اوسه هیڅ لګښت نه دی ثبت شوی", + fin_date: "نیټه", + fin_vendor: "عرضه‌کوونکی", + fin_category: "کټګورۍ", + fin_amount: "اندازه", + fin_status: "حالت", + fin_description: "تشریح", + fin_cat_rent: "کرایه", + fin_cat_utilities: "خدمات (اوبه/برق)", + fin_cat_supplies: "دفتري توکي", + fin_cat_travel: "سفر او ترانسپورت", + fin_cat_services: "خدمات", + fin_cat_other: "نور", + fin_status_draft: "مسوده", + fin_status_approved: "تصویب شوی", + fin_status_paid: "تادیه شوی", + fin_status_rejected: "رد شوی", + fin_approve: "تصویب", + fin_reject: "رد", + fin_mark_paid: "تادیه شو", + fin_expense_added: "لګښت ثبت شو", + fin_expense_updated: "لګښت تازه شو", + fin_form_invalid: "مهرباني وکړئ ساحې سمې ډکې کړئ", + fin_add_journal: "سند ثبتول", + fin_trial_balance: "ازمویښتي بیلانس", + fin_ledger_empty: "تر اوسه په لوی دفتر کې ثبت نشته", + fin_account: "حساب", + fin_debit: "پوروړی", + fin_credit: "پورندوی", + fin_total: "ټول", + fin_journal: "د ورځپاڼې اسناد", + fin_journal_empty: "تر اوسه سند نشته", + fin_memo: "شرح", + fin_debit_account: "پوروړی حساب", + fin_credit_account: "پورندوی حساب", + fin_journal_invalid: "سند ناسم دی (حسابونه او اندازه وګورئ)", + fin_journal_added: "سند ثبت شو", +}; + +const en: Dict = { + app_title: "WorkTrack Manager Portal", + tagline: "Smart workforce management for Afghanistan", + brand_tagline: "HR platform", + + nav_menu: "Menu", + theme_to_dark: "Dark mode", + theme_to_light: "Light mode", + nav_dashboard: "Dashboard", + nav_employees: "Employees", + nav_attendance: "Attendance", + nav_leave: "Leave", + nav_payroll: "Payroll", + nav_logout: "Sign out", + + pay_title: "Payroll", + pay_run: "Run payroll", + pay_running: "Calculating…", + pay_period: "Period", + pay_year: "Year", + pay_month: "Month", + pay_status: "Status", + pay_employees: "Employees", + pay_total_gross: "Total gross", + pay_total_net: "Total net", + pay_runs_empty: "No payroll run yet. Pick a month and press “Run payroll”.", + pay_run_done: "Payroll calculated for {0} employees", + pay_exited_title: "{0} people marked as having left worked this month", + pay_exited_body: "No payslip is produced for someone who has left. If they are owed this month, set them back to Active, run payroll again, then mark them as having left.", + pay_skipped_title: "{0} employees were left out of this run", + pay_skipped_body: "They have no basic salary on file. Set it on the Employees page, then run payroll again.", + pay_provisional: "Provisional", + pay_provisional_hint: "This month has not ended. The figures cover only the days elapsed so far — run it again once the month closes.", + pay_view: "View", + pay_employee: "Employee", + pay_gross: "Gross", + pay_deductions: "Deductions", + pay_net: "Net", + pay_worked_days: "Worked days", + pay_tax: "Tax", + pay_ctc: "Cost to company", + pay_total_tax: "Total tax", + pay_back_to_runs: "Back to runs", + + login_email: "Work email", + login_password: "Password", + login_submit: "Sign in", + login_error: "Email or password is incorrect", + login_no_access: "This account has no portal access", + login_signing_in: "Signing in…", + login_show_password: "Show password", + login_hide_password: "Hide password", + login_welcome: "Welcome back", + auth_point_1: "Attendance via GPS, QR and kiosk", + auth_point_2: "Payroll, leave and attendance corrections", + auth_point_3: "Solar Hijri calendar and live reports", + + signup_title: "Register your company", + signup_sub: "Spin up your company workspace in seconds", + signup_company: "Company name", + signup_admin_first: "Admin first name", + signup_admin_last: "Admin last name", + signup_submit: "Create workspace", + signup_creating: "Creating…", + signup_no_account: "New company? Register", + signup_have_account: "Have an account? Sign in", + signup_email_exists: "This email is already registered", + signup_password_hint: "At least 8 characters", + verify_title: "Verify your email", + verify_sent: "We sent a verification link to {0}.", + verify_hint: "Open the link, then sign in. If it is not in your inbox, check your spam folder.", + verify_resend: "Send the link again", + verify_resent: "Link sent again.", + verify_back: "Back to sign in", + + dash_title: "Today at a glance", + dash_active_employees: "Active employees", + dash_present: "Present", + dash_absent: "Absent", + dash_on_leave: "On leave", + dash_late: "Late", + dash_half_day: "Half day", + dash_pending_leave: "Pending leave", + dash_attendance_rate: "Attendance rate", + dash_trend: "Attendance trend (7 days)", + + emp_title: "Employees", + emp_add: "Add employee", + emp_edit: "Edit", + emp_edit_title: "Edit employee", + emp_updated: "Employee updated", + emp_login: "Login account", + emp_reset_password: "Reset password", + emp_set_password: "Set password", + emp_set_password_ph: "Custom password (empty = random, min 8 chars)", + emp_code: "Code", + emp_code_auto: "Automatic", + emp_role_unchanged: "Leave unchanged", + adv_title: "Salary advances", + adv_sub: "Money given before payday, taken back out of the wage", + adv_add: "Record an advance", + adv_empty: "No advances recorded", + adv_employee: "Employee", + adv_pick_employee: "Choose an employee", + adv_principal: "Amount", + adv_instalment: "Monthly instalment", + adv_instalment_hint: "Leave blank to take it all at the next payroll", + adv_in_full: "In full", + adv_outstanding: "Outstanding", + adv_issued: "Date", + adv_status: "Status", + adv_status_outstanding: "Outstanding", + adv_status_settled: "Settled", + adv_status_cancelled: "Cancelled", + adv_cancel: "Cancel", + adv_cancel_confirm: "Cancel {0}'s advance? This cannot be undone.", + adv_cancelled: "Advance cancelled", + adv_saved: "Advance recorded", + adv_total_owed: "Total outstanding: {0}", + adv_note: "Deducted at the next payroll run, after tax. If the month's pay does not cover it, only what it can bear is taken and the rest stays outstanding — a payslip is never negative.", + adv_note_field: "Note", + doc_title: "Documents", + doc_empty: "No documents on file", + doc_add: "Add a document", + doc_delete: "Delete", + doc_type: "Type", + doc_number: "Number", + doc_expires_on: "Expires on", + doc_expires_hint: "Leave empty if it does not expire (a tazkira, for example)", + doc_no_expiry: "Does not expire", + doc_expires: "Until {0}", + doc_expired: "Expired — {0}", + doc_type_tazkira: "Tazkira", + doc_type_contract: "Employment contract", + doc_type_work_permit: "Work permit", + doc_type_health_certificate: "Health certificate", + doc_type_licence: "Licence", + doc_type_other: "Other", + notif_title: "Notifications", + notif_empty: "Nothing new", + notif_mark_all: "Mark all read", + notif_unread: "{0} unread", + piece_title: "Piece work", + piece_sub: "How many pieces each person finished — for anybody on a piece rate, this IS their wage", + piece_add: "Record a count", + piece_empty: "No counts recorded", + piece_employee: "Employee", + piece_pick_employee: "Choose an employee", + piece_date: "Date", + piece_quantity: "Quantity", + piece_note: "Note", + piece_delete: "Delete", + piece_delete_confirm: "Delete this entry for {0}?", + piece_deleted: "Deleted", + piece_saved: "Recorded", + piece_err_required: "An employee and a quantity are required", + emp_pay_model: "How they are paid", + pay_model_monthly: "Monthly salary", + pay_model_daily: "Daily wage", + pay_model_piece: "Piece rate", + pay_model_hint_monthly: "A fixed monthly salary; days absent are deducted from it.", + pay_model_hint_daily: "A wage for each day worked. A day not worked is simply not paid — and not deducted on top.", + pay_model_hint_piece: "A rate per piece. Record the counts on the payroll page.", + emp_rate_daily: "Daily wage ({0})", + emp_rate_piece: "Rate per piece ({0})", + pay_rate_monthly: "Monthly salary", + pay_rate_daily: "Daily wage", + pay_rate_piece: "Rate per piece", + sheet_open: "Payment sheet", + sheet_csv: "Export to Excel", + sheet_print: "Print", + sheet_title: "Salary payment sheet", + sheet_period: "Period {0}", + sheet_row: "#", + sheet_code: "Code", + sheet_name: "Name", + sheet_gross: "Gross", + sheet_deductions: "Deductions", + sheet_net: "Payable", + sheet_signature: "Signature / thumbprint", + sheet_total: "Total — {0} people", + sheet_paid_by: "Paid by:", + sheet_approved_by: "Approved by:", + adv_err_required: "An employee and an amount are required", + adv_err_instalment: "The instalment must be a positive number", + adv_err_instalment_big: "The instalment is larger than the advance itself", + signup_business_type: "Type of business", + signup_business_type_skip: "Choose one (optional)", + signup_business_type_hint: "Only picks your starting settings; every one of them can be changed later.", + biz_office: "Office", + biz_construction: "Construction", + biz_retail: "Shop or retail", + biz_tailoring: "Tailoring workshop", + biz_warehouse: "Warehouse and logistics", + biz_security: "Security company", + biz_restaurant: "Restaurant or cafe", + biz_clinic: "Clinic or pharmacy", + biz_school: "School or courses", + biz_ngo: "NGO or project", + biz_exchange: "Money exchange", + biz_transport: "Transport company", + biz_production: "Production or bakery", + biz_agriculture: "Agriculture", + biz_hospitality: "Hotel or guesthouse", + emp_name: "Name", + emp_email: "Email", + emp_branch: "Branch", + emp_status: "Status", + emp_face: "Face", + emp_face_enrolled: "Enrolled", + emp_face_not_enrolled: "Not enrolled", + emp_face_reset: "Reset", + emp_face_reset_done: "Face enrollment cleared", + emp_basic_salary: "Monthly basic salary ({0})", + emp_basic_salary_ph: "e.g. 30000", + emp_basic_salary_hint: "Without a basic salary this employee gets no payslip when payroll runs.", + emp_type: "Employment", + emp_join_date: "Join date", + emp_phone: "Phone", + emp_save: "Save", + emp_cancel: "Cancel", + emp_created: "Employee added", + emp_search: "Search name or code…", + emp_load_more: "Load more", + emp_empty: "No employees found", + emp_role: "Role", + emp_create_login: "Create a mobile-app login", + emp_password_optional: "Password (blank = auto temp password)", + emp_credentials_title: "Employee account created", + emp_credentials_hint: "Share these with the employee so they can sign into the mobile app:", + emp_credentials_email: "Email", + emp_credentials_password: "Temp password", + emp_credentials_copy: "Copy", + emp_credentials_copied: "Copied", + emp_credentials_done: "Done", + role_employee: "Employee", + role_team_lead: "Team lead", + role_branch_manager: "Branch manager", + role_hr_admin: "HR admin", + role_payroll_admin: "Payroll admin", + role_auditor: "Auditor", + + status_active: "Active", + status_on_leave: "On leave", + status_suspended: "Suspended", + status_exited: "Exited", + + type_full_time: "Full time", + type_part_time: "Part time", + type_contract: "Contract", + type_intern: "Intern", + + att_title: "Attendance monitoring", + att_date: "Date", + att_present: "Present", + att_absent: "Absent", + att_first_in: "First in", + att_worked: "Worked", + att_late_by: "Late by {0} min", + att_empty: "No data for this day", + att_needs_review: "Needs review", + att_needs_review_hint: "Recorded without face verification", + att_face_verified: "Face verified", + att_rejected: "Not counted", + att_rejected_hint: "{0} — tried at {1} but was not counted", + att_rejected_count: "{0} rejected punches", + att_reason_geofence: "Outside the work area", + att_reason_time_skew: "Device clock is wrong", + att_reason_too_old: "Sent too late", + att_reason_travel: "Impossible travel", + att_reason_kiosk: "Invalid kiosk code", + att_reason_unknown: "Unknown reason", + att_inactive: "Inactive", + att_company_time: "Company time", + att_view_selfie: "View check-in photo", + att_view_daily: "Daily", + att_view_weekly: "Weekly", + att_week_total: "Week total", + + reg_title: "Attendance correction requests", + reg_pending: "Pending corrections", + reg_date: "Date", + reg_requested_in: "Proposed in", + reg_requested_out: "Proposed out", + reg_reason: "Reason", + reg_approve: "Approve", + reg_reject: "Reject", + reg_empty: "No attendance corrections pending", + reg_approved: "Correction approved", + reg_rejected: "Correction rejected", + reg_reject_prompt: "Enter the rejection reason:", + + nav_shifts: "Shifts", + nav_kiosk: "Kiosk", + nav_settings: "Settings", + dz_title: "Close the company account", + dz_hint: "Deletes the company and everything in it. You have {0} days to change your mind; after that it cannot be undone.", + dz_start: "Close the company account", + dz_confirm_body: "This permanently destroys:", + dz_loses_attendance: "Every attendance and leave record", + dz_loses_payroll: "Every payroll run, payslip and ledger entry", + dz_loses_logins: "The login of every employee and kiosk", + dz_type_name: "Type the company name exactly to confirm: {0}", + dz_reason: "Reason (optional)", + dz_reason_ph: "For your own records", + dz_confirm: "Yes, close the account", + dz_scheduled: "Closure scheduled", + dz_cancelled: "Cancelled — the account is active again", + dz_scheduled_title: "This account is scheduled to close", + dz_scheduled_body: "The data is deleted on {0}. Until then you can cancel at any time and everything comes back untouched.", + dz_cancel: "Cancel and reactivate", + att_weekend: "Weekend", + att_holiday: "Public holiday", + att_closed_hint: "Nobody is expected in, and the day does not count as absence.", + hol_title: "Working calendar", + hol_hint: "Days the company is closed. Payroll counts neither these nor the weekend as absence.", + hol_year: "Year", + hol_seed: "Generate this year's fixed holidays", + hol_seeded: "{0} added", + hol_seed_none: "Nothing to add", + hol_date: "Date", + hol_name: "Occasion", + hol_name_ph: "e.g. Eid al-Fitr", + hol_paid: "Paid", + hol_paid_yes: "Paid", + hol_paid_no: "Unpaid", + hol_add: "Add", + hol_remove: "Remove", + hol_saved: "Saved", + hol_empty: "No holidays recorded for this year", + hol_generated: "Generated", + hol_lunar_note: "Nawroz and Independence Day are fixed in the Solar Hijri calendar and are generated for you. Eid al-Fitr, Eid al-Adha, Ashura and Mawlid follow the moon and are announced by sighting — add those yourself each year.", + nav_devices: "Devices & licence", + nav_work: "Work & projects", + + work_title: "Work & projects", + work_intro: "Everyone should know which part of the job they are on today and tomorrow. This is where you decide it.", + work_tab_board: "Today", + work_tab_plan: "Plan", + work_tab_projects: "Projects", + work_tab_teams: "Teams", + work_tab_mine: "My work", + + work_board_title: "Who is on what", + work_board_empty: "Nothing assigned for this day yet.", + work_prev_day: "Previous day", + work_next_day: "Next day", + work_date: "Date", + work_assign: "Assign work", + work_person: "Employee", + work_task: "Task", + work_items: "tasks", + + work_plan_title: "Work plan", + work_plan_empty: "Nothing assigned in this range.", + work_from: "From", + work_to_optional: "To (optional)", + work_span: "Range", + work_span_day: "One day", + work_span_week: "One week", + work_span_month: "One month", + work_project: "Project", + work_all_projects: "All projects", + work_when: "When", + work_who: "Assigned to", + work_status: "Status", + work_edit: "Edit", + work_delete: "Delete", + work_delete_confirm: "Delete \u201c{0}\u201d?", + work_deleted: "Deleted", + work_saved: "Saved", + + work_projects_title: "Projects", + work_projects_hint: "The company's contracts and sites. Work belongs to a project.", + work_projects_empty: "No projects yet.", + work_project_add: "New project", + work_project_edit: "Edit project", + work_project_name: "Project name", + work_code: "Code", + work_detail: "Detail", + work_choose_project: "Choose a project", + work_pstatus_planned: "Planned", + work_pstatus_active: "Active", + work_pstatus_paused: "Paused", + work_pstatus_done: "Finished", + + work_teams_title: "Work teams", + work_teams_hint: "A team is a group of people, not a department. Team work reaches every member.", + work_teams_empty: "No teams yet.", + work_team_add: "New team", + work_team_edit: "Edit team", + work_team_name: "Team name", + work_team_active: "Active", + work_team_inactive: "Inactive", + work_members: "members", + work_team_delete_confirm: "Delete the team \u201c{0}\u201d? Work already assigned stays with the same people.", + + work_status_planned: "Planned", + work_status_in_progress: "In progress", + work_status_done: "Done", + work_status_blocked: "Blocked", + + work_task_ph: "e.g. Shuttering, third floor", + work_location: "Location", + work_location_ph: "e.g. Block B, third floor", + work_team: "Team", + work_no_team: "No team (individual)", + work_also_people: "Additional people", + work_keep_assignees: "Leave the team and people untouched and this task keeps the people it already has.", + + work_mine_title: "Your work", + work_mine_hint: "The same view your staff get on the phone.", + work_today: "Today", + work_next: "Next working day", + work_day_free: "Nothing assigned to you for this day.", + work_day_weekend: "This is the weekly day off.", + work_day_holiday: "This is a public holiday.", + work_with: "With", + work_start: "Started", + work_finish: "Finished", + dev_title: "Devices & licence", + dev_no_access: "You do not have access to this section", + dev_saved: "Saved", + dev_license: "Licence", + dev_plan: "Plan", + dev_plan_free: "Free", + dev_plan_standard: "Standard", + dev_plan_enterprise: "Enterprise", + dev_limit: "Device limit", + dev_status: "Status", + dev_status_active: "Active", + dev_status_suspended: "Suspended", + dev_status_expired: "Expired", + dev_expires: "Expires", + dev_expires_hint: "Empty = never expires", + dev_enforce: "Enforce the device limit", + dev_expires_never: "Never", + dev_license_vendor_hint: "Your licence is issued by Linumic. To add device seats, extend the expiry date or change your plan, contact us — the details are in your handover pack.", + dev_seats_used: "{0} of {1} devices", + dev_registered: "Registered devices", + dev_empty: "No devices registered yet", + dev_device: "Device", + dev_unnamed: "Unnamed", + dev_type: "Type", + dev_type_mobile: "Mobile", + dev_type_kiosk: "Kiosk", + dev_employee: "Employee", + dev_last_seen: "Last seen", + dev_revoked: "Revoked", + dev_revoke: "Revoke", + dev_restore: "Restore", + + kiosk_title: "Scan to check in", + kiosk_hint: "Open the WorkTrack app and tap “Scan kiosk QR”.", + kiosk_rotates: "The code refreshes every 30 seconds", + kiosk_exit: "Exit kiosk mode", + + kioskdev_title: "Kiosk devices", + kioskdev_hint: "Create a kiosk login per tablet so it stays on the check-in screen without a manager sign-in.", + kioskdev_add: "Create kiosk login", + kioskdev_label: "Device name (e.g. Entrance kiosk)", + kioskdev_empty: "No kiosk devices yet", + kioskdev_created: "Kiosk login created", + kioskdev_creds_hint: "Enter this email and password in the tablet's browser (this same URL) to lock it to the kiosk screen:", + kioskdev_reset: "New password", + kioskdev_reset_done: "New password generated", + + shift_title: "Shifts & roster", + shift_defs: "Shift definitions", + shift_add: "New shift", + shift_edit: "Edit shift", + shift_name: "Shift name", + shift_code: "Code", + shift_start: "Start", + shift_end: "End", + shift_break: "Break (min)", + shift_grace_in: "Grace in (min)", + shift_grace_out: "Grace out (min)", + shift_time: "Time", + shift_active: "Active", + shift_inactive: "Inactive", + shift_night: "Night shift", + shift_24h: "24-hour", + shift_hours: "{0} h", + shift_empty: "No shifts defined yet — add one", + shift_saved: "Shift saved", + + roster_title: "Daily roster", + roster_date: "Date", + roster_assign: "Assign shift", + roster_pick_shift: "Pick a shift", + roster_pick_emps: "Employees", + roster_from: "From", + roster_to: "To (optional)", + roster_submit: "Save roster", + roster_assigned: "Roster saved for {0} entries", + roster_empty: "No roster for this day", + roster_employee: "Employee", + roster_shift_col: "Shift", + + set_title: "Company settings", + set_features: "Features", + set_features_hint: "Choose which modules are enabled for your company. A disabled module is hidden from the menu.", + set_policies: "Work policies", + set_profile: "Profile", + feat_shifts: "Shift scheduling", + feat_leave: "Leave", + feat_payroll: "Payroll", + feat_regularization: "Attendance corrections", + feat_announcements: "Announcements", + feat_geofencing: "Geofencing (GPS)", + feat_qr: "QR kiosk", + feat_face: "Face recognition", + pol_daily_hours: "Standard daily hours", + pol_weekend: "Weekend day(s)", + pol_grace: "Late grace (min)", + pol_overtime: "Calculate overtime", + pol_currency: "Currency", + pol_timezone: "Timezone", + set_save: "Save changes", + set_saving: "Saving…", + set_saved: "Settings saved", + wd_sat: "Sat", + wd_sun: "Sun", + wd_mon: "Mon", + wd_tue: "Tue", + wd_wed: "Wed", + wd_thu: "Thu", + wd_fri: "Fri", + + leave_title: "Leave approvals", + leave_employee: "Employee", + leave_dates: "Dates", + leave_days: "Days", + leave_reason: "Reason", + leave_approve: "Approve", + leave_reject: "Reject", + leave_empty: "No leave requests pending", + leave_approved: "Request approved", + leave_rejected: "Request rejected", + leave_reject_prompt: "Enter the rejection reason:", + + att_status_present: "Present", + att_status_absent: "Absent", + att_status_half_day: "Half day", + att_status_leave: "Leave", + att_status_holiday: "Public holiday", + att_status_week_off: "Week off", + att_status_pending: "Pending", + + common_retry: "Retry", + common_loading: "Loading…", + common_error: "Something went wrong", + common_days: "days", + common_minutes: "min", + common_cancel: "Cancel", + sup_issues: "Issues raised", + sup_issues_hint: "Send it from here instead of by email — your company, plan and seat count travel with it.", + sup_raise: "Raise an issue", + sup_subject: "Subject", + sup_subject_ph: "The app will not install on older phones", + sup_detail: "Detail", + sup_detail_ph: "What happened, on how many devices, since when?", + sup_send: "Send", + sup_issue_sent: "Received. We will be in touch.", + sup_issue_failed: "Not sent. Call us if it is urgent.", + sup_status_open: "Open", + sup_status_waiting: "Waiting", + sup_status_resolved: "Resolved", + sup_title: "Support", + sup_intro: "WorkTrack is a Linumic product. Contact us for support, to renew your licence, or to add devices.", + sup_phone: "Phone", + sup_email: "Email", + sup_web: "Website", + sup_company_id: "Your company ID", + sup_company_id_hint: "Quote this when you contact us — your licence is issued against it.", + sup_copy: "Copy", + sup_copied: "Copied", + comp_scope: "Applies to", + comp_scope_all: "Everyone", + comp_scope_individual: "Selected employees only", + comp_scope_hint: "Everyone means it applies by default. Selected employees means it applies to nobody until it is given to them on their own record.", + empc_title: "This employee's earnings and deductions", + empc_hint: "These affect this employee's payslip only.", + empc_applies: "Applies", + empc_amount: "Their amount", + empc_default: "Default", + empc_default_hint: "Blank = the company amount", + empc_all_note: "everyone gets this", + empc_individual_note: "only if selected", + empc_none: "No earnings or deductions are defined for the company yet. Define them on the Payroll page first.", + empc_saved: "Saved", + empc_save_first: "Save the employee first, then set their earnings and deductions.", + comp_title: "Earnings and deductions", + comp_hint: "Anything on a payslip other than basic salary, income tax and the absence deduction is defined here — a transport allowance, a loan repayment, and so on.", + comp_add: "Add item", + comp_name: "Name", + comp_name_ph: "Transport allowance", + comp_code: "Code", + comp_code_hint: "Capitals, digits and _ only. It appears on the payslip.", + comp_type: "Type", + comp_type_earning: "Earning", + comp_type_deduction: "Deduction", + comp_type_employer_cost: "Employer cost", + comp_calc: "How it is calculated", + comp_calc_fixed: "Fixed amount", + comp_calc_percent_of_basic: "Percent of basic salary", + comp_calc_percent_of_gross: "Percent of gross", + comp_calc_earning_hint: "Percent of gross is not offered for an earning — gross is made up of the earnings themselves.", + comp_value_afn: "Amount (AFN)", + comp_value_percent: "Percent", + comp_taxable: "Taxable", + comp_taxable_hint: "When off, this earning is left out of the income-tax base.", + comp_tax_exempt: "Exempt", + comp_amount: "Amount", + comp_afn: "AFN", + comp_of_basic: "of basic", + comp_of_gross: "of gross", + comp_status: "Status", + comp_active: "Active", + comp_inactive: "Inactive", + comp_edit: "Edit", + comp_activate: "Activate", + comp_deactivate: "Deactivate", + comp_activated: "Activated", + comp_deactivated: "Deactivated", + comp_saved: "Saved", + comp_empty: "No earnings or deductions yet. Payslips show basic salary, income tax and the absence deduction only.", + comp_rerun_hint: "Changing this list does not alter payslips already produced. Run that month's payroll again to apply it.", + comp_err_name: "Enter a name.", + comp_err_code: "The code must be capitals, digits and _ only (24 characters at most).", + comp_err_value: "The amount cannot be negative.", + comp_err_percent: "A percentage cannot be more than 100.", + comp_err_duplicate: "Code {0} is already in use.", + common_yes: "Yes", + common_no: "No", + common_close: "Close", + common_save: "Save", + common_saving: "Saving…", + + nav_finance: "Finance", + feat_finance: "Finance & accounting", + fin_title: "Finance & Accounting", + fin_tab_overview: "Overview", + fin_tab_expenses: "Expenses", + fin_tab_ledger: "Ledger", + fin_net_profit: "Net profit", + fin_income: "Income", + fin_expense: "Expenses", + fin_payroll_cost: "Payroll cost", + fin_expenses_approved: "Approved expenses", + fin_expenses_pending: "Pending approval", + fin_position: "Financial position", + fin_assets: "Assets", + fin_liabilities: "Liabilities", + fin_net: "Net", + fin_trend: "Monthly trend", + fin_no_trend: "No data yet", + fin_month: "Month", + fin_add_expense: "Add expense", + fin_expenses_empty: "No expenses recorded yet", + fin_date: "Date", + fin_vendor: "Vendor", + fin_category: "Category", + fin_amount: "Amount", + fin_status: "Status", + fin_description: "Description", + fin_cat_rent: "Rent", + fin_cat_utilities: "Utilities", + fin_cat_supplies: "Office supplies", + fin_cat_travel: "Travel & transport", + fin_cat_services: "Services", + fin_cat_other: "Other", + fin_status_draft: "Draft", + fin_status_approved: "Approved", + fin_status_paid: "Paid", + fin_status_rejected: "Rejected", + fin_approve: "Approve", + fin_reject: "Reject", + fin_mark_paid: "Mark paid", + fin_expense_added: "Expense added", + fin_expense_updated: "Expense updated", + fin_form_invalid: "Please fill the fields correctly", + fin_add_journal: "Add entry", + fin_trial_balance: "Trial balance", + fin_ledger_empty: "No ledger entries yet", + fin_account: "Account", + fin_debit: "Debit", + fin_credit: "Credit", + fin_total: "Total", + fin_journal: "Journal entries", + fin_journal_empty: "No journal entries yet", + fin_memo: "Memo", + fin_debit_account: "Debit account", + fin_credit_account: "Credit account", + fin_journal_invalid: "Invalid entry (check accounts and amount)", + fin_journal_added: "Entry posted", +}; + +export const DICTIONARIES: Record = { fa, ps, en }; diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..9d63662 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,37 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter } from "react-router-dom"; +import { LocaleProvider } from "./i18n/LocaleProvider"; +import { ThemeProvider } from "./ui/ThemeProvider"; +import { AuthProvider } from "./auth/AuthProvider"; +import { App } from "./App"; +import { SetupNeeded } from "./ui/SetupNeeded"; +import { firebaseConfigured } from "./firebase"; +import "./styles.css"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 30_000 }, + }, +}); + +createRoot(document.getElementById("root")!).render( + + + + {firebaseConfigured ? ( + + + + + + + + ) : ( + + )} + + + , +); diff --git a/web/src/pages/AdvancesCard.test.tsx b/web/src/pages/AdvancesCard.test.tsx new file mode 100644 index 0000000..428a2da --- /dev/null +++ b/web/src/pages/AdvancesCard.test.tsx @@ -0,0 +1,223 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import type { Advance } from "../api/types"; +import { LocaleProvider } from "../i18n/LocaleProvider"; +import { DICTIONARIES } from "../i18n/strings"; + +/** + * The advances card. + * + * The arithmetic and the idempotency are settled in the backend. What only + * this card can get wrong is the part a person touches: offering to cancel + * something that has already been repaid, sending a blank instalment as a + * number, or letting somebody record an advance without saying who it is for. + */ + +const state = vi.hoisted(() => ({ + advances: [] as Advance[], + created: [] as Record[], + cancelled: [] as string[], + permissions: new Set(["payroll:read", "payroll:run"]), +})); + +vi.mock("../api/hooks", () => ({ + useAdvances: () => ({ + data: state.advances, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useEmployees: () => ({ + data: { data: [{ id: "e1", firstName: "Ali", lastName: "Rahimi" }] }, + isLoading: false, + isError: false, + }), + useCreateAdvance: () => ({ + mutateAsync: async (body: Record) => { + state.created.push(body); + return { id: "a_new" }; + }, + isPending: false, + }), + useCancelAdvance: () => ({ + mutateAsync: async (id: string) => { + state.cancelled.push(id); + return { id }; + }, + isPending: false, + }), +})); + +vi.mock("../auth/AuthProvider", () => ({ + useHasPermission: () => (p: string) => state.permissions.has(p), +})); + +const { AdvancesCard } = await import("./AdvancesCard"); + +function advance(over: Partial = {}): Advance { + return { + id: "a1", + employeeId: "e1", + employeeName: "Ali Rahimi", + principal: 5000, + instalment: null, + issuedOn: "2026-09-01", + note: null, + repaid: 0, + outstanding: 5000, + status: "OUTSTANDING", + ...over, + }; +} + +function show(): void { + render( + + + , + ); +} + +function openForm(): void { + show(); + fireEvent.click(screen.getByRole("button", { name: DICTIONARIES.fa.adv_add })); +} + +function field(label: string): HTMLElement { + const wrap = screen.getByText(label).closest(".field") as HTMLElement; + return (wrap.querySelector("input") ?? wrap.querySelector("select")) as HTMLElement; +} + +beforeEach(() => { + state.advances = []; + state.created = []; + state.cancelled = []; + state.permissions = new Set(["payroll:read", "payroll:run"]); + vi.spyOn(window, "confirm").mockReturnValue(true); +}); + +describe("what the list shows", () => { + it("says an advance with no instalment comes out in one go", () => { + // A dash would leave somebody guessing whether it means nothing is owed. + state.advances = [advance({ instalment: null })]; + show(); + expect(screen.getAllByText(DICTIONARIES.fa.adv_in_full).length).toBeGreaterThan(0); + }); + + it("offers to cancel an untouched advance", () => { + state.advances = [advance({ repaid: 0 })]; + show(); + expect(screen.getByRole("button", { name: DICTIONARIES.fa.adv_cancel })).toBeInTheDocument(); + }); + + it("does not offer to cancel one that has been partly repaid", () => { + // A payslip was issued against it, and that cannot be unsaid. The server + // refuses too; offering the button would just produce an error. + state.advances = [advance({ repaid: 2000, outstanding: 3000 })]; + show(); + expect(screen.queryByRole("button", { name: DICTIONARIES.fa.adv_cancel })).not.toBeInTheDocument(); + }); + + it("does not offer to cancel a settled or cancelled one", () => { + state.advances = [ + advance({ id: "s", status: "SETTLED", repaid: 5000, outstanding: 0 }), + advance({ id: "c", status: "CANCELLED" }), + ]; + show(); + expect(screen.queryByRole("button", { name: DICTIONARIES.fa.adv_cancel })).not.toBeInTheDocument(); + }); + + it("leaves cancelled advances out of the total owed", () => { + state.advances = [ + advance({ id: "live", outstanding: 3000 }), + advance({ id: "dead", status: "CANCELLED", outstanding: 9000 }), + ]; + show(); + + // The sentence is "total outstanding: X", so match the paragraph rather + // than a bare number. What matters is that the 9,000 nobody owes any more + // is not in it — that figure would overstate what payroll is about to take. + const label = DICTIONARIES.fa.adv_total_owed.split("{0}")[0].trim(); + const totals = screen.getByText((_, el) => (el?.textContent ?? "").startsWith(label)); + expect(totals.textContent).not.toMatch(/9|۹/); + }); + + it("hides every write control from somebody who may only look", () => { + state.permissions = new Set(["payroll:read"]); + state.advances = [advance()]; + show(); + expect(screen.queryByRole("button", { name: DICTIONARIES.fa.adv_add })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: DICTIONARIES.fa.adv_cancel })).not.toBeInTheDocument(); + }); +}); + +describe("recording one", () => { + it("sends a blank instalment as null, not as a number", () => { + // Number("") is 0, and a zero instalment would repay nothing forever. + openForm(); + fireEvent.change(field(DICTIONARIES.fa.adv_employee), { target: { value: "e1" } }); + fireEvent.change(field(DICTIONARIES.fa.adv_principal), { target: { value: "5000" } }); + fireEvent.submit(document.querySelector("form.modal") as HTMLFormElement); + + return waitFor(() => { + expect(state.created).toHaveLength(1); + expect(state.created[0].instalment).toBeNull(); + expect(state.created[0].principal).toBe(5000); + }); + }); + + it("refuses to record one with nobody attached to it", async () => { + openForm(); + fireEvent.change(field(DICTIONARIES.fa.adv_principal), { target: { value: "5000" } }); + fireEvent.submit(document.querySelector("form.modal") as HTMLFormElement); + + await waitFor(() => + expect(screen.getByText(DICTIONARIES.fa.adv_err_required)).toBeInTheDocument(), + ); + expect(state.created).toHaveLength(0); + }); + + it("catches an instalment bigger than the advance before the server does", async () => { + // Not a server rule — it would work, settling in one go — but it is + // somebody misreading the field, and saying so here is cheaper. + openForm(); + fireEvent.change(field(DICTIONARIES.fa.adv_employee), { target: { value: "e1" } }); + fireEvent.change(field(DICTIONARIES.fa.adv_principal), { target: { value: "5000" } }); + fireEvent.change(field(DICTIONARIES.fa.adv_instalment), { target: { value: "9000" } }); + fireEvent.submit(document.querySelector("form.modal") as HTMLFormElement); + + await waitFor(() => + expect(screen.getByText(DICTIONARIES.fa.adv_err_instalment_big)).toBeInTheDocument(), + ); + expect(state.created).toHaveLength(0); + }); +}); + +describe("the form's own labels", () => { + it("labels the note field with a label, not with the paragraph under the table", () => { + // These were one key. The field a manager types "for medicine" into was + // labelled with the whole explanation of when the deduction happens — + // which every test passed, because none of them read a label. + openForm(); + const labels = [...document.querySelectorAll("form.modal label")].map((l) => l.textContent ?? ""); + expect(labels).toContain(DICTIONARIES.fa.adv_note_field); + for (const label of labels) { + expect(label.length, `a label is a paragraph: "${label.slice(0, 40)}…"`).toBeLessThan(40); + } + }); +}); + +describe("the three dictionaries", () => { + it("has every advance string in all of them", () => { + // Edited by hand: one added to Dari and forgotten in Pashto shows the raw + // key to exactly the users least likely to report it. + const keys = Object.keys(DICTIONARIES.fa).filter((k) => k.startsWith("adv_")); + expect(keys.length).toBeGreaterThan(15); + for (const lang of ["ps", "en"] as const) { + for (const key of keys) { + expect((DICTIONARIES[lang] as Record)[key], `${lang} missing ${key}`) + .toBeTruthy(); + } + } + }); +}); diff --git a/web/src/pages/AdvancesCard.tsx b/web/src/pages/AdvancesCard.tsx new file mode 100644 index 0000000..2bd5eff --- /dev/null +++ b/web/src/pages/AdvancesCard.tsx @@ -0,0 +1,313 @@ +import { type FormEvent, useState } from "react"; +import { useAdvances, useCancelAdvance, useCreateAdvance, useEmployees } from "../api/hooks"; +import { ApiError } from "../api/client"; +import type { Advance } from "../api/types"; +import { useHasPermission } from "../auth/AuthProvider"; +import { useI18n } from "../i18n/LocaleProvider"; +import { Chip, EmptyState, ErrorState, LoadingState, Toast } from "../ui/components"; + +/** + * Money handed to somebody before payday. + * + * It lives on the payroll page rather than on its own, because an advance only + * means anything next to the run that takes it back: whoever records one is + * about to decide what comes out of a wage, and should be looking at the + * payroll while they do it. + */ +export function AdvancesCard() { + const { t, num, shamsi } = useI18n(); + + // Grouped, then digits localised — the same two steps the payroll table on + // this page takes. Ungrouped, ۶۰۰۰ and ۶۰۰۰۰ are one glance apart, and this + // column is money somebody is about to lose from a wage. + // + // The currency word follows the salary-components card next to it ("افغانی") + // rather than the payroll table above it ("AFN"). Those two already disagree + // and standardising them is a change for its own commit, not a side effect + // of this one. + const money = (n: number): string => `${num(n.toLocaleString("en-US"))} ${t("comp_afn")}`; + const can = useHasPermission(); + const advances = useAdvances(null); + const employees = useEmployees({}); + const cancel = useCancelAdvance(); + + const [showForm, setShowForm] = useState(false); + const [toast, setToast] = useState(null); + const canWrite = can("payroll:run"); + + function flash(message: string): void { + setToast(message); + window.setTimeout(() => setToast(null), 2800); + } + + async function onCancel(advance: Advance): Promise { + // Cancelling is not undoable and the person has the money, so it asks. + if (!window.confirm(t("adv_cancel_confirm", advance.employeeName))) return; + try { + await cancel.mutateAsync(advance.id); + flash(t("adv_cancelled")); + } catch (err) { + // The server refuses once anything has been repaid — a payslip was + // issued against it, and that cannot be unsaid. + flash(err instanceof ApiError ? err.message : t("common_error")); + } + } + + const rows = advances.data ?? []; + const live = rows.filter((a) => a.status !== "CANCELLED"); + const owed = live.reduce((sum, a) => sum + a.outstanding, 0); + + return ( +
    +
    +
    +

    {t("adv_title")}

    +

    {t("adv_sub")}

    +
    + {canWrite && ( + + )} +
    + + {advances.isLoading ? ( + + ) : advances.isError ? ( + void advances.refetch()} /> + ) : rows.length === 0 ? ( + + ) : ( + <> + {owed > 0 && ( +

    + {t("adv_total_owed", money(owed))} +

    + )} +
    +
    + + + + + + + + + {canWrite && + + + {rows.map((a) => ( + + + + {/* An advance with no instalment comes out in one go at the + next run, which is worth saying rather than showing a + dash somebody has to interpret. */} + + + + + {canWrite && ( + + )} + + ))} + +
    {t("adv_employee")}{t("adv_principal")}{t("adv_instalment")}{t("adv_outstanding")}{t("adv_issued")}{t("adv_status")}} +
    {a.employeeName}{money(a.principal)}{a.instalment === null ? t("adv_in_full") : money(a.instalment)}{money(a.outstanding)}{shamsi(a.issuedOn, { withYear: true })} + + {t(`adv_status_${a.status.toLowerCase()}`)} + + + {a.status === "OUTSTANDING" && a.repaid === 0 && ( + + )} +
    + + + )} + + {showForm && ( + ({ + id: e.id, + name: `${e.firstName} ${e.lastName}`.trim(), + }))} + onClose={() => setShowForm(false)} + onSaved={() => { + setShowForm(false); + flash(t("adv_saved")); + }} + /> + )} + + {toast && } +

    + {t("adv_note")} +

    + + ); +} + +function AdvanceForm({ + employees, + onClose, + onSaved, +}: { + employees: { id: string; name: string }[]; + onClose: () => void; + onSaved: () => void; +}) { + const { t } = useI18n(); + const create = useCreateAdvance(); + const [error, setError] = useState(null); + const [form, setForm] = useState({ + employeeId: "", + principal: "", + // Blank means "take it all next payroll", which is right for the common + // case: a small advance a few days before payday. + instalment: "", + issuedOn: isoToday(), + note: "", + }); + + function set(key: K, value: string): void { + setForm((f) => ({ ...f, [key]: value })); + } + + async function onSubmit(e: FormEvent): Promise { + e.preventDefault(); + setError(null); + + const principal = Number(form.principal); + if (!form.employeeId || !Number.isFinite(principal) || principal <= 0) { + setError(t("adv_err_required")); + return; + } + const instalment = form.instalment.trim() === "" ? null : Number(form.instalment); + if (instalment !== null && (!Number.isFinite(instalment) || instalment <= 0)) { + setError(t("adv_err_instalment")); + return; + } + // Not a server rule, but an instalment larger than the advance is somebody + // misreading the field, and it is cheaper to say so here. + if (instalment !== null && instalment > principal) { + setError(t("adv_err_instalment_big")); + return; + } + + try { + await create.mutateAsync({ + employeeId: form.employeeId, + principal, + instalment, + issuedOn: form.issuedOn, + note: form.note.trim() || null, + }); + onSaved(); + } catch (err) { + setError(err instanceof ApiError ? err.message : t("common_error")); + } + } + + return ( +
    +
    e.stopPropagation()} onSubmit={(e) => void onSubmit(e)}> +

    {t("adv_add")}

    + +
    + + +
    + +
    +
    + + set("principal", e.target.value)} + /> +
    +
    + + set("instalment", e.target.value)} + /> + {t("adv_instalment_hint")} +
    +
    + +
    + + set("issuedOn", e.target.value)} + /> +
    + +
    + {/* adv_note is the paragraph under the table explaining WHEN the + deduction happens. This is the field somebody types "for medicine" + into — a different thing, and it had been sharing the key. */} + + set("note", e.target.value)} /> +
    + + {error &&

    {error}

    } + +
    + + +
    +
    +
    + ); +} + +function isoToday(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( + d.getDate(), + ).padStart(2, "0")}`; +} diff --git a/web/src/pages/AttendancePage.test.tsx b/web/src/pages/AttendancePage.test.tsx new file mode 100644 index 0000000..78407a4 --- /dev/null +++ b/web/src/pages/AttendancePage.test.tsx @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import type { AttendanceOverviewRow } from "../api/types"; +import { LocaleProvider } from "../i18n/LocaleProvider"; + +const overviewRows = vi.hoisted(() => ({ current: [] as AttendanceOverviewRow[] })); +const dayInfo = vi.hoisted(() => ({ + current: { date: "2026-07-24", kind: "WORKING" as string, holidayName: null as string | null }, +})); + +vi.mock("../api/hooks", () => ({ + useAttendanceDay: () => ({ data: dayInfo.current, isLoading: false, isError: false }), + useAttendanceOverview: () => ({ + data: overviewRows.current, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + // The corrections block hides itself when empty; keep it out of the way. + usePendingRegularizations: () => ({ data: [], isLoading: false, isError: false }), + useDecideRegularization: () => ({ mutateAsync: vi.fn() }), +})); + +vi.mock("../auth/AuthProvider", () => ({ + useHasPermission: () => () => true, + // The board resolves dates in the company's zone, not the viewer's. + useAuth: () => ({ me: { timezone: "Asia/Kabul" } }), +})); + +// Imported after the mocks so the page picks them up. +const { AttendancePage } = await import("./AttendancePage"); + +function row(over: Partial = {}): AttendanceOverviewRow { + return { + employeeId: "e1", + employeeName: "Ahmad Karimi", + branchId: null, + status: "PRESENT", + firstInAt: "2026-07-24T04:00:00.000Z", + lastOutAt: null, + workedMinutes: 300, + lateMinutes: 0, + hasCheckInSelfie: false, + checkInFaceVerified: false, + needsReview: false, + employeeStatus: "ACTIVE", + rejectedCount: 0, + rejectedReason: null, + rejectedAt: null, + ...over, + }; +} + +function renderPage() { + return render( + + + , + { wrapper: ({ children }: { children: ReactNode }) => <>{children} }, + ); +} + +beforeEach(() => { + localStorage.setItem("worktrack.locale", "en"); + overviewRows.current = []; + dayInfo.current = { date: "2026-07-24", kind: "WORKING", holidayName: null }; +}); + +describe("attendance overview — face verification signals", () => { + it("flags a row whose check-in was not face-verified", () => { + overviewRows.current = [row({ needsReview: true })]; + renderPage(); + // Once in the table row, once in the summary tile. + expect(screen.getAllByText("Needs review").length).toBeGreaterThan(0); + expect(screen.getByTitle("Recorded without face verification")).toBeInTheDocument(); + }); + + it("leaves a verified row unflagged", () => { + overviewRows.current = [row({ checkInFaceVerified: true })]; + renderPage(); + expect(screen.queryByText("Needs review")).not.toBeInTheDocument(); + expect(screen.getByTitle("Face verified")).toBeInTheDocument(); + }); + + it("counts every flagged row in the summary tile", () => { + overviewRows.current = [ + row({ employeeId: "a", needsReview: true }), + row({ employeeId: "b", needsReview: true }), + row({ employeeId: "c" }), + ]; + renderPage(); + const tile = screen.getByText("Needs review", { selector: ".label" }).parentElement; + expect(tile).toHaveTextContent("2"); + }); + + it("hides the summary tile when nothing needs review", () => { + overviewRows.current = [row(), row({ employeeId: "b" })]; + renderPage(); + expect(screen.queryByText("Needs review", { selector: ".label" })).not.toBeInTheDocument(); + }); + + it("localizes the flag into Dari", () => { + localStorage.setItem("worktrack.locale", "fa"); + overviewRows.current = [row({ needsReview: true })]; + renderPage(); + expect(screen.getAllByText("نیاز به بررسی").length).toBeGreaterThan(0); + }); +}); + +describe("attendance overview — refused punches", () => { + it("names the rule that refused the punch instead of staying silent", () => { + overviewRows.current = [ + row({ + status: "PENDING", + firstInAt: null, + workedMinutes: 0, + rejectedCount: 1, + rejectedReason: "GEOFENCE_VIOLATION", + rejectedAt: "2026-07-24T04:03:00.000Z", + }), + ]; + renderPage(); + expect(screen.getByText("Outside the work area")).toBeInTheDocument(); + }); + + it("falls back to a readable reason for an unknown code", () => { + overviewRows.current = [row({ rejectedCount: 1, rejectedReason: "SOMETHING_NEW" })]; + renderPage(); + expect(screen.getByText("Unknown reason")).toBeInTheDocument(); + }); + + it("shows how many punches were refused when there is more than one", () => { + overviewRows.current = [ + row({ rejectedCount: 3, rejectedReason: "TIME_SKEW" }), + ]; + renderPage(); + expect(screen.getByText(/Device clock is wrong\s*\(3\)/)).toBeInTheDocument(); + }); + + it("counts a refused-punch row in the attention tile", () => { + overviewRows.current = [row({ rejectedCount: 1, rejectedReason: "GEOFENCE_VIOLATION" })]; + renderPage(); + const tile = screen.getByText("Needs review", { selector: ".label" }).parentElement; + expect(tile).toHaveTextContent("1"); + }); +}); + +describe("attendance overview — non-active employees", () => { + it("marks an employee who is no longer active but still has a day", () => { + overviewRows.current = [row({ employeeStatus: "SUSPENDED" })]; + renderPage(); + expect(screen.getByText("Inactive")).toBeInTheDocument(); + expect(screen.getByText("Ahmad Karimi")).toBeInTheDocument(); + }); + + it("does not mark active employees", () => { + overviewRows.current = [row()]; + renderPage(); + expect(screen.queryByText("Inactive")).not.toBeInTheDocument(); + }); +}); + +/** + * A Friday and a public holiday used to look exactly like a day the whole + * company failed to turn up: every row ABSENT, nothing saying why. + */ +describe("attendance overview — days the office is shut", () => { + it("says nothing on an ordinary working day", () => { + overviewRows.current = [row()]; + renderPage(); + expect(screen.queryByText("Weekend")).not.toBeInTheDocument(); + expect(screen.queryByText("Public holiday")).not.toBeInTheDocument(); + }); + + it("names the weekend rather than showing a wall of absences", () => { + dayInfo.current = { date: "2026-07-24", kind: "WEEKEND", holidayName: null }; + overviewRows.current = [row({ status: "ABSENT", workedMinutes: 0 })]; + renderPage(); + expect(screen.getByText("Weekend")).toBeInTheDocument(); + expect(screen.getByText(/does not count as absence/)).toBeInTheDocument(); + }); + + it("names the actual holiday when there is one", () => { + dayInfo.current = { date: "2026-08-19", kind: "HOLIDAY", holidayName: "روز استقلال" }; + overviewRows.current = [row({ status: "ABSENT", workedMinutes: 0 })]; + renderPage(); + expect(screen.getByText("روز استقلال")).toBeInTheDocument(); + }); + + it("falls back to a generic label when the holiday has no name", () => { + dayInfo.current = { date: "2026-08-19", kind: "HOLIDAY", holidayName: null }; + overviewRows.current = [row()]; + renderPage(); + expect(screen.getByText("Public holiday")).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/AttendancePage.tsx b/web/src/pages/AttendancePage.tsx new file mode 100644 index 0000000..a1fe9bb --- /dev/null +++ b/web/src/pages/AttendancePage.tsx @@ -0,0 +1,361 @@ +import { useMemo, useState } from "react"; +import { + useAttendanceDay, + useAttendanceOverview, + useDecideRegularization, + usePendingRegularizations, +} from "../api/hooks"; +import type { Regularization } from "../api/types"; +import { api } from "../api/client"; +import { useAuth, useHasPermission } from "../auth/AuthProvider"; +import { isoTodayIn, isViewerDayDifferent } from "../time"; +import { AttendanceWeekly } from "./AttendanceWeekly"; +import { useI18n } from "../i18n/LocaleProvider"; +import { EmptyState, ErrorState, LoadingState, StatusChip, Toast } from "../ui/components"; + +export function AttendancePage() { + const { t, num, shamsi } = useI18n(); + const can = useHasPermission(); + const { me } = useAuth(); + // Attendance belongs to the company's calendar day. A manager viewing from + // another timezone must not be shown their own "today" — in Ottawa that is + // yesterday in Kabul, and the board would look empty. + const timeZone = me?.timezone ?? "Asia/Kabul"; + const companyToday = isoTodayIn(timeZone); + const [date, setDate] = useState(companyToday); + const overview = useAttendanceOverview(date, timeZone); + const day = useAttendanceDay(date, timeZone); + + const canApprove = can("attendance:approve"); + const [preview, setPreview] = useState(null); + + // Fetched only when a manager actually opens a photo. + async function openSelfie(employeeId: string) { + try { + const { data } = await api.get<{ selfie: string }>( + `/attendance/days/${employeeId}/${date}/selfie`, + ); + setPreview(data.selfie); + } catch { + setPreview(null); + } + } + const [view, setView] = useState<"daily" | "weekly">("daily"); + + const summary = useMemo(() => { + const rows = overview.data ?? []; + const present = rows.filter((r) => r.status === "PRESENT" || r.status === "HALF_DAY").length; + // One "look at this" count: an unverified face and a refused punch are + // both things a manager has to act on. + const needsReview = rows.filter((r) => r.needsReview || r.rejectedCount > 0).length; + return { present, absent: rows.length - present, needsReview, total: rows.length }; + }, [overview.data]); + + return ( + <> +
    +

    {t("att_title")}

    +
    + {/* Only surfaces when the viewer is in another country, where "today" + on their own clock is not the company's working day. */} + {isViewerDayDifferent(timeZone) && ( + + {t("att_company_time")} + + )} +
    + + +
    + {shamsi(date, { withYear: true })} + setDate(e.target.value)} + /> +
    +
    + + {canApprove && } + + {/* A closed day explains itself. Otherwise every row reads ABSENT and the + manager is left wondering whether the whole company skipped work. */} + {view === "daily" && day.data && day.data.kind !== "WORKING" && ( +
    + + {day.data.kind === "WEEKEND" + ? t("att_weekend") + : (day.data.holidayName || t("att_holiday"))} + + {t("att_closed_hint")} +
    + )} + + {view === "weekly" ? ( + + ) : overview.isLoading ? ( + + ) : overview.isError ? ( + void overview.refetch()} /> + ) : (overview.data?.length ?? 0) === 0 ? ( + + ) : ( + <> +
    +
    +
    {num(summary.present)}
    +
    {t("att_present")}
    +
    +
    +
    {num(summary.absent)}
    +
    {t("att_absent")}
    +
    + {/* Only shown when there is something to act on, so it never adds noise. */} + {summary.needsReview > 0 && ( +
    +
    {num(summary.needsReview)}
    +
    {t("att_needs_review")}
    +
    + )} +
    + +
    + + + + + + + + + + + {overview.data!.map((r) => ( + + + + + + + ))} + +
    {t("leave_employee")}{t("emp_status")}{t("att_first_in")}{t("att_worked")}
    + + {r.hasCheckInSelfie && ( + + )} + {r.employeeName} + {r.checkInFaceVerified && ( + + ✓ + + )} + {/* Only reached when a non-active employee still has a + day record — worth saying so rather than looking + like a roster mistake. */} + {r.employeeStatus !== "ACTIVE" && ( + {t("att_inactive")} + )} + + + + {r.lateMinutes > 0 && ( + + {t("att_late_by", num(r.lateMinutes))} + + )} + {r.needsReview && ( + + {t("att_needs_review")} + + )} + {/* A refused punch is why an otherwise-worked day can read + as empty; say which rule rejected it, and when. */} + {r.rejectedCount > 0 && ( + + {t(rejectReasonKey(r.rejectedReason))} + {r.rejectedCount > 1 && ` (${num(r.rejectedCount)})`} + + )} + {r.firstInAt ? formatTime(r.firstInAt, num, timeZone) : "—"} + {r.workedMinutes > 0 + ? `${num(Math.floor(r.workedMinutes / 60))}:${num( + String(r.workedMinutes % 60).padStart(2, "0"), + )}` + : "—"} +
    +
    + + )} + + {preview && ( +
    setPreview(null)}> + e.stopPropagation()} /> +
    + )} + + ); +} + +/** Manager review of employee-filed attendance corrections (attendance:approve). */ +function RegularizationApprovals() { + const { t, num, shamsi } = useI18n(); + const { me } = useAuth(); + const timeZone = me?.timezone ?? "Asia/Kabul"; + const pending = usePendingRegularizations(true); + const decide = useDecideRegularization(); + const [toast, setToast] = useState(null); + const [busyId, setBusyId] = useState(null); + + function flash(message: string) { + setToast(message); + window.setTimeout(() => setToast(null), 2500); + } + + async function onDecide(req: Regularization, decision: "APPROVE" | "REJECT") { + let note: string | null = null; + if (decision === "REJECT") { + note = window.prompt(t("reg_reject_prompt")) ?? ""; + if (!note.trim()) return; // rejection requires a note + } + setBusyId(req.id); + try { + await decide.mutateAsync({ id: req.id, decision, note }); + flash(decision === "APPROVE" ? t("reg_approved") : t("reg_rejected")); + } catch { + flash(t("common_error")); + } finally { + setBusyId(null); + } + } + + const rows = pending.data ?? []; + // Hide the whole block when there is nothing to review, so it never adds noise. + if (pending.isLoading || pending.isError || rows.length === 0) return null; + + return ( +
    +

    + {t("reg_pending")}{" "} + + {num(rows.length)} + +

    +
    + + + + + + + + + + + + {rows.map((req) => ( + + + + + + + + + ))} + +
    {t("leave_employee")}{t("reg_date")}{t("reg_requested_in")}{t("reg_requested_out")}{t("reg_reason")} +
    {req.employeeName ?? req.employeeId}{shamsi(req.date, { withYear: true })}{req.requestedInAt ? formatTime(req.requestedInAt, num, timeZone) : "—"}{req.requestedOutAt ? formatTime(req.requestedOutAt, num, timeZone) : "—"}{req.reason} +
    + + +
    +
    +
    + {toast && } +
    + ); +} + +/** Server invalidReason → translation key, so the manager sees a rule, not a code. */ +function rejectReasonKey(reason: string | null): string { + switch (reason) { + case "GEOFENCE_VIOLATION": + return "att_reason_geofence"; + case "TIME_SKEW": + return "att_reason_time_skew"; + case "TOO_OLD": + return "att_reason_too_old"; + case "IMPLAUSIBLE_TRAVEL": + return "att_reason_travel"; + case "KIOSK_TOKEN_INVALID": + return "att_reason_kiosk"; + default: + return "att_reason_unknown"; + } +} + +/** + * Renders a punch time in the company's zone. Using the viewer's clock would + * show a Kabul morning check-in as the previous evening for a manager abroad. + */ +function formatTime( + iso: string, + num: (v: string | number) => string, + timeZone: string, +): string { + return num( + new Intl.DateTimeFormat("en-GB", { + timeZone, + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(new Date(iso)), + ); +} + diff --git a/web/src/pages/AttendanceWeekly.test.tsx b/web/src/pages/AttendanceWeekly.test.tsx new file mode 100644 index 0000000..64a7f8d --- /dev/null +++ b/web/src/pages/AttendanceWeekly.test.tsx @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import type { WeeklyAttendance } from "../api/types"; +import { LocaleProvider } from "../i18n/LocaleProvider"; + +const weekData = vi.hoisted(() => ({ current: null as WeeklyAttendance | null })); + +vi.mock("../api/hooks", () => ({ + useWeeklyAttendance: () => ({ + data: weekData.current, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), +})); + +vi.mock("../auth/AuthProvider", () => ({ + useAuth: () => ({ me: { timezone: "Asia/Kabul" } }), +})); + +const { AttendanceWeekly } = await import("./AttendanceWeekly"); + +const DATES = [ + "2026-07-25", + "2026-07-26", + "2026-07-27", + "2026-07-28", + "2026-07-29", + "2026-07-30", + "2026-07-31", +]; + +function day(over: Partial = {}) { + return { + date: DATES[0], + status: "ABSENT", + workedMinutes: 0, + lateMinutes: 0, + needsReview: false, + rejectedCount: 0, + ...over, + }; +} + +function week(days: Partial[]): WeeklyAttendance { + const full = DATES.map((date, i) => day({ ...days[i], date })); + return { + from: DATES[0], + to: DATES[6], + dates: DATES, + rows: [ + { + employeeId: "e1", + employeeName: "Ahmad Karimi", + employeeStatus: "ACTIVE", + branchId: null, + days: full, + totalWorkedMinutes: full.reduce((s, d) => s + d.workedMinutes, 0), + presentDays: full.filter((d) => d.status !== "ABSENT").length, + lateDays: full.filter((d) => d.lateMinutes > 0).length, + needsReviewDays: full.filter((d) => d.needsReview || d.rejectedCount > 0).length, + }, + ], + }; +} + +beforeEach(() => { + localStorage.setItem("worktrack.locale", "en"); + weekData.current = null; +}); + +describe("weekly attendance report", () => { + it("lays out one column per day of the week", () => { + weekData.current = week([]); + render( + + + , + ); + // Employee + seven days + total. + expect(screen.getAllByRole("columnheader")).toHaveLength(9); + }); + + it("shows worked hours, not raw minutes", () => { + weekData.current = week([{ status: "PRESENT", workedMinutes: 450 }]); + render( + + + , + ); + expect(screen.getAllByText("7:30").length).toBeGreaterThan(0); + }); + + it("totals the week", () => { + weekData.current = week([ + { status: "PRESENT", workedMinutes: 480 }, + { status: "PRESENT", workedMinutes: 480 }, + ]); + render( + + + , + ); + const row = screen.getByText("Ahmad Karimi").closest("tr")!; + expect(within(row).getByText("16:00")).toBeInTheDocument(); + }); + + it("marks an absent day rather than printing a zero", () => { + weekData.current = week([]); + render( + + + , + ); + const row = screen.getByText("Ahmad Karimi").closest("tr")!; + expect(within(row).getAllByText("—")).toHaveLength(7); + }); + + it("flags a late day and a day needing review", () => { + weekData.current = week([ + { status: "PRESENT", workedMinutes: 400, lateMinutes: 25 }, + { status: "PRESENT", workedMinutes: 400, needsReview: true }, + ]); + render( + + + , + ); + expect(screen.getByTitle("Late by 25 min")).toBeInTheDocument(); + expect(screen.getByTitle("Needs review")).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/AttendanceWeekly.tsx b/web/src/pages/AttendanceWeekly.tsx new file mode 100644 index 0000000..43de1d7 --- /dev/null +++ b/web/src/pages/AttendanceWeekly.tsx @@ -0,0 +1,124 @@ +import { useWeeklyAttendance } from "../api/hooks"; +import { useAuth } from "../auth/AuthProvider"; +import { useI18n } from "../i18n/LocaleProvider"; +import { EmptyState, ErrorState, LoadingState } from "../ui/components"; + +/** + * A week of attendance, all staff at once. + * + * The daily board answers "who is in right now". This answers the question a + * manager actually asks at the end of a week — who kept their hours, who was + * short, and where the gaps fell — which needs the days side by side rather + * than seven visits to the daily view. + */ +export function AttendanceWeekly({ date }: { date: string }) { + const { t, num, shamsi } = useI18n(); + const { me } = useAuth(); + const timeZone = me?.timezone ?? "Asia/Kabul"; + const week = useWeeklyAttendance(date, timeZone); + + if (week.isLoading) return ; + if (week.isError) { + return void week.refetch()} />; + } + const data = week.data; + if (!data || data.rows.length === 0) return ; + + return ( +
    + + + + + {data.dates.map((d) => ( + + ))} + + + + + {data.rows.map((row) => ( + + + {row.days.map((day) => ( + + ))} + + + ))} + +
    {t("leave_employee")} + {shamsi(d)} + {t("att_week_total")}
    + {row.employeeName} + {row.employeeStatus !== "ACTIVE" && ( + + {t("att_inactive")} + + )} + + 0} + flagged={day.needsReview || day.rejectedCount > 0} + num={num} + lateTitle={t("att_late_by", num(day.lateMinutes))} + flaggedTitle={t("att_needs_review")} + /> + + {formatHours(row.totalWorkedMinutes, num)} +
    +
    + ); +} + +/** + * One day for one person. Hours are the useful number; late and flagged days + * are marked rather than spelled out, so a week of thirty-five cells stays + * scannable instead of becoming a wall of text. + */ +function DayCell({ + workedMinutes, + status, + late, + flagged, + num, + lateTitle, + flaggedTitle, +}: { + workedMinutes: number; + status: string; + late: boolean; + flagged: boolean; + num: (v: string | number) => string; + lateTitle: string; + flaggedTitle: string; +}) { + if (workedMinutes === 0 && status === "ABSENT") { + return ; + } + return ( + + {formatHours(workedMinutes, num)} + {late && ( + + ● + + )} + {flagged && ( + + ● + + )} + + ); +} + +/** "7:30" — hours and minutes, in the reader's digits. */ +function formatHours(minutes: number, num: (v: string | number) => string): string { + if (minutes === 0) return num(0); + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return `${num(h)}:${num(String(m).padStart(2, "0"))}`; +} diff --git a/web/src/pages/DangerZoneCard.test.tsx b/web/src/pages/DangerZoneCard.test.tsx new file mode 100644 index 0000000..430538c --- /dev/null +++ b/web/src/pages/DangerZoneCard.test.tsx @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import type { CompanyDeletion } from "../api/types"; +import { LocaleProvider } from "../i18n/LocaleProvider"; + +const state = vi.hoisted(() => ({ + deletion: { status: "NONE", requestedAt: null, requestedBy: null, purgeAfter: null, reason: null, graceDays: 30 } as CompanyDeletion, + permissions: new Set(), + requested: [] as unknown[], + cancelled: 0, +})); + +vi.mock("../api/hooks", () => ({ + useCompanyDeletion: () => ({ data: state.deletion, isLoading: false, isError: false, refetch: vi.fn() }), + useRequestCompanyDeletion: () => ({ + mutateAsync: vi.fn(async (b: unknown) => { state.requested.push(b); return state.deletion; }), + isPending: false, + }), + useCancelCompanyDeletion: () => ({ + mutateAsync: vi.fn(async () => { state.cancelled += 1; return state.deletion; }), + isPending: false, + }), +})); + +vi.mock("../auth/AuthProvider", () => ({ + useHasPermission: () => (p: string) => state.permissions.has(p), + useAuth: () => ({ me: { companyName: "شرکت ساختمانی کابل" } }), +})); + +const { DangerZoneCard } = await import("./DangerZoneCard"); + +function renderCard() { + return render(); +} + +beforeEach(() => { + localStorage.setItem("worktrack.locale", "en"); + state.deletion = { status: "NONE", requestedAt: null, requestedBy: null, purgeAfter: null, reason: null, graceDays: 30 }; + state.permissions = new Set(["company:delete"]); + state.requested = []; + state.cancelled = 0; +}); + +describe("closing the company account", () => { + it("is invisible to anyone who is not a company admin", () => { + state.permissions = new Set(["settings:write", "payroll:run"]); + const { container } = renderCard(); + expect(container).toBeEmptyDOMElement(); + }); + + it("spells out what closing destroys before asking", () => { + renderCard(); + fireEvent.click(screen.getByText("Close the company account", { selector: "button" })); + expect(screen.getByText("Every attendance and leave record")).toBeInTheDocument(); + expect(screen.getByText("Every payroll run, payslip and ledger entry")).toBeInTheDocument(); + expect(screen.getByText("The login of every employee and kiosk")).toBeInTheDocument(); + }); + + it("keeps the confirm button dead until the name is typed exactly", () => { + renderCard(); + fireEvent.click(screen.getByText("Close the company account", { selector: "button" })); + const confirm = screen.getByText("Yes, close the account").closest("button")!; + expect(confirm).toBeDisabled(); + + fireEvent.change(screen.getByPlaceholderText("شرکت ساختمانی کابل"), { + target: { value: "شرکت" }, + }); + expect(confirm).toBeDisabled(); + + fireEvent.change(screen.getByPlaceholderText("شرکت ساختمانی کابل"), { + target: { value: "شرکت ساختمانی کابل" }, + }); + expect(confirm).toBeEnabled(); + }); + + it("sends the typed name when confirmed", () => { + renderCard(); + fireEvent.click(screen.getByText("Close the company account", { selector: "button" })); + fireEvent.change(screen.getByPlaceholderText("شرکت ساختمانی کابل"), { + target: { value: "شرکت ساختمانی کابل" }, + }); + fireEvent.click(screen.getByText("Yes, close the account")); + expect(state.requested).toHaveLength(1); + }); + + it("forgets a half-typed name when the dialog is dismissed", () => { + renderCard(); + fireEvent.click(screen.getByText("Close the company account", { selector: "button" })); + fireEvent.change(screen.getByPlaceholderText("شرکت ساختمانی کابل"), { + target: { value: "شرکت ساختمانی کابل" }, + }); + fireEvent.click(screen.getByText("Cancel")); + fireEvent.click(screen.getByText("Close the company account", { selector: "button" })); + expect(screen.getByText("Yes, close the account").closest("button")).toBeDisabled(); + }); + + it("leads with the countdown when a closure is already scheduled", () => { + state.deletion = { + status: "SCHEDULED", requestedAt: "2026-08-01T00:00:00Z", requestedBy: "admin", + purgeAfter: "2026-08-31", reason: null, graceDays: 30, + }; + renderCard(); + expect(screen.getByText("This account is scheduled to close")).toBeInTheDocument(); + // 31 August 2026 is 9 Sunbula 1405. + expect(screen.getByText(/9 سنبله 1405/)).toBeInTheDocument(); + }); + + it("offers no way to close an account that is already closing", () => { + state.deletion = { + status: "SCHEDULED", requestedAt: "2026-08-01T00:00:00Z", requestedBy: "admin", + purgeAfter: "2026-08-31", reason: null, graceDays: 30, + }; + renderCard(); + expect(screen.queryByText("Yes, close the account")).not.toBeInTheDocument(); + }); + + it("cancels on request", () => { + state.deletion = { + status: "SCHEDULED", requestedAt: "2026-08-01T00:00:00Z", requestedBy: "admin", + purgeAfter: "2026-08-31", reason: null, graceDays: 30, + }; + renderCard(); + fireEvent.click(screen.getByText("Cancel and reactivate")); + expect(state.cancelled).toBe(1); + }); +}); diff --git a/web/src/pages/DangerZoneCard.tsx b/web/src/pages/DangerZoneCard.tsx new file mode 100644 index 0000000..8d4920e --- /dev/null +++ b/web/src/pages/DangerZoneCard.tsx @@ -0,0 +1,174 @@ +import { useState } from "react"; +import { + useCancelCompanyDeletion, + useCompanyDeletion, + useRequestCompanyDeletion, +} from "../api/hooks"; +import { useAuth, useHasPermission } from "../auth/AuthProvider"; +import { useI18n } from "../i18n/LocaleProvider"; +import { toShamsi } from "../shamsi/solarHijri"; +import { ErrorState, LoadingState, Toast } from "../ui/components"; + +const SHAMSI_MONTHS = [ + "حمل", "ثور", "جوزا", "سرطان", "اسد", "سنبله", + "میزان", "عقرب", "قوس", "جدی", "دلو", "حوت", +]; + +/** + * Closing the company account. + * + * Deliberately the least convenient thing on the page: it asks for the company + * name in full, states plainly what is destroyed and when, and stays reversible + * for the whole grace period. The scheduled state is shown first and loudly, + * because an account quietly counting down to deletion is the one thing here + * nobody should be able to miss. + */ +export function DangerZoneCard() { + const { t, num } = useI18n(); + const { me } = useAuth(); + const can = useHasPermission(); + const canDelete = can("company:delete"); + + const deletion = useCompanyDeletion(canDelete); + const request = useRequestCompanyDeletion(); + const cancel = useCancelCompanyDeletion(); + + const [open, setOpen] = useState(false); + const [confirmName, setConfirmName] = useState(""); + const [reason, setReason] = useState(""); + const [toast, setToast] = useState(null); + + // Only a company admin sees this at all. + if (!canDelete) return null; + + const flash = (m: string) => { + setToast(m); + window.setTimeout(() => setToast(null), 3000); + }; + + const companyName = me?.companyName ?? ""; + const nameMatches = confirmName.trim() === companyName.trim(); + + function shamsiLabel(iso: string): string { + const s = toShamsi(iso); + return `${num(s.day)} ${SHAMSI_MONTHS[s.month - 1]} ${num(s.year)}`; + } + + async function onRequest() { + try { + await request.mutateAsync({ confirmName: confirmName.trim(), reason: reason.trim() || null }); + setOpen(false); + setConfirmName(""); + setReason(""); + flash(t("dz_scheduled")); + } catch { + flash(t("common_error")); + } + } + + async function onCancel() { + try { + await cancel.mutateAsync(); + flash(t("dz_cancelled")); + } catch { + flash(t("common_error")); + } + } + + const scheduled = deletion.data?.status === "SCHEDULED"; + + return ( +
    +

    {t("dz_title")}

    + + {deletion.isLoading ? ( + + ) : deletion.isError ? ( + void deletion.refetch()} /> + ) : scheduled ? ( + <> +
    + {t("dz_scheduled_title")} +

    + {t( + "dz_scheduled_body", + deletion.data?.purgeAfter ? shamsiLabel(deletion.data.purgeAfter) : "—", + )} +

    +
    + + + ) : ( + <> +

    + {t("dz_hint", num(deletion.data?.graceDays ?? 30))} +

    + + {!open ? ( + + ) : ( +
    +

    {t("dz_confirm_body")}

    +
      +
    • {t("dz_loses_attendance")}
    • +
    • {t("dz_loses_payroll")}
    • +
    • {t("dz_loses_logins")}
    • +
    + + + + + +
    + + +
    +
    + )} + + )} + + {toast && } +
    + ); +} diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..521ba79 --- /dev/null +++ b/web/src/pages/DashboardPage.tsx @@ -0,0 +1,218 @@ +import type { ReactNode } from "react"; +import { useAttendanceTrend, useKpis } from "../api/hooks"; +import { useI18n } from "../i18n/LocaleProvider"; +import { ErrorState, LoadingState } from "../ui/components"; +import { toShamsi } from "../shamsi/solarHijri"; + +export function DashboardPage() { + const { t, num, shamsi } = useI18n(); + const kpis = useKpis(); + const trend = useAttendanceTrend(); + + if (kpis.isLoading) return ; + if (kpis.isError || !kpis.data) { + return void kpis.refetch()} />; + } + const k = kpis.data; + + return ( + <> +
    +

    {t("dash_title")}

    + {shamsi(k.date, { withYear: true })} +
    + +
    + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } + /> +
    + +
    +

    {t("dash_trend")}

    + {trend.data && trend.data.length > 0 ? ( + ({ + present: p.present, + cap: shamsi(p.date), + isToday: p.date === k.date, + }))} + /> + ) : ( +
    {t("common_loading")}
    + )} +
    + + {/* Keep an eye on the "as of" note reading the same Shamsi calendar. */} +

    + {t("att_date")}: {num(toShamsi(k.date).day)} {shamsi(k.date, { withYear: true })} +

    + + ); +} + +function Kpi({ + value, + label, + accent, + icon, +}: { + value: string; + label: string; + accent?: "red" | "amber" | "orange"; + icon?: ReactNode; +}) { + return ( +
    + {icon && {icon}} +
    {value}
    +
    {label}
    +
    + ); +} + +/* ---- KPI icons ---- */ +function Ic({ children }: { children: ReactNode }) { + return ( + + ); +} +const IcPeople = () => ( + +); +const IcCheck = () => ( + +); +const IcCross = () => ( + +); +const IcPlane = () => ( + +); +const IcClock = () => ( + +); +const IcHalf = () => ( + +); +const IcInbox = () => ( + +); +const IcTrend = () => ( + +); + +/** + * Smooth area/line trend — a tasteful borrow from the reference designs, kept + * high-contrast. Fixed viewBox scales responsively; non-scaling strokes stay + * crisp. Rendered left→right (time increasing), the convention even in RTL. + */ +function Trend({ points }: { points: { present: number; cap: string; isToday: boolean }[] }) { + const { num } = useI18n(); + const W = 720; + const H = 200; + const padX = 18; + const padTop = 30; + const padBottom = 14; + const max = Math.max(1, ...points.map((p) => p.present)); + const n = points.length; + + const xy = points.map((p, i) => { + const x = n === 1 ? W / 2 : padX + (i / (n - 1)) * (W - 2 * padX); + const y = padTop + (1 - p.present / max) * (H - padTop - padBottom); + return [x, y] as const; + }); + const line = smoothPath(xy); + const area = `${line} L ${xy[n - 1][0]},${H - padBottom} L ${xy[0][0]},${H - padBottom} Z`; + const today = points.findIndex((p) => p.isToday); + const todayPt = today >= 0 ? xy[today] : null; + + return ( + <> + + + + + + + + {[0.25, 0.5, 0.75].map((f) => ( + + ))} + + + {xy.map(([x, y], i) => ( + + ))} + {todayPt && ( + + + + + + {num(points[today].present)} + + + + )} + +
    + {points.map((p, i) => ( + + {p.cap} + + ))} +
    + + ); +} + +/** Catmull-Rom → cubic Bézier for a smooth curve through the points. */ +function smoothPath(pts: readonly (readonly [number, number])[]): string { + if (pts.length < 2) return pts.length ? `M ${pts[0][0]},${pts[0][1]}` : ""; + let d = `M ${pts[0][0]},${pts[0][1]}`; + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[i - 1] ?? pts[i]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[i + 2] ?? p2; + const c1x = p1[0] + (p2[0] - p0[0]) / 6; + const c1y = p1[1] + (p2[1] - p0[1]) / 6; + const c2x = p2[0] - (p3[0] - p1[0]) / 6; + const c2y = p2[1] - (p3[1] - p1[1]) / 6; + d += ` C ${c1x},${c1y} ${c2x},${c2y} ${p2[0]},${p2[1]}`; + } + return d; +} diff --git a/web/src/pages/DevicesPage.test.tsx b/web/src/pages/DevicesPage.test.tsx new file mode 100644 index 0000000..d9957fa --- /dev/null +++ b/web/src/pages/DevicesPage.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { License, LicensedDevice } from "../api/types"; +import { LocaleProvider } from "../i18n/LocaleProvider"; + +const state = vi.hoisted(() => ({ + license: null as License | null, + devices: [] as LicensedDevice[], + permissions: new Set(), +})); + +vi.mock("../api/hooks", () => ({ + useLicense: () => ({ + data: state.license, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useDevices: () => ({ + data: state.devices, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useSaveLicense: () => ({ mutateAsync: vi.fn(), isPending: false }), + useSetDeviceStatus: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +vi.mock("../auth/AuthProvider", () => ({ + useHasPermission: () => (p: string) => state.permissions.has(p), +})); + +const { DevicesPage } = await import("./DevicesPage"); + +function device(over: Partial = {}): LicensedDevice { + return { + deviceId: "and-abc123", + type: "MOBILE", + label: null, + platform: "ANDROID", + model: "Pixel 10", + appVersion: "1.0.0", + employeeId: "e1", + branchId: null, + status: "ACTIVE", + activatedAt: "2026-08-20T08:00:00.000Z", + lastSeenAt: "2026-08-24T08:00:00.000Z", + ...over, + }; +} + +function renderPage() { + return render( + + + , + ); +} + +beforeEach(() => { + localStorage.setItem("worktrack.locale", "en"); + state.license = { + plan: "STANDARD", + deviceLimit: 3, + status: "ACTIVE", + expiresAt: null, + enforceDevices: false, + }; + state.devices = []; + state.permissions = new Set(["devices:read", "devices:manage"]); +}); + +describe("devices & licence", () => { + it("refuses the page to someone without device access", () => { + state.permissions = new Set(); + renderPage(); + expect(screen.getByText("You do not have access to this section")).toBeInTheDocument(); + }); + + it("shows how many seats the licence has left", () => { + state.devices = [device(), device({ deviceId: "and-two" })]; + renderPage(); + expect(screen.getByText("2 of 3 devices")).toBeInTheDocument(); + }); + + it("does not count a revoked device against the licence", () => { + state.devices = [device(), device({ deviceId: "and-two", status: "REVOKED" })]; + renderPage(); + expect(screen.getByText("1 of 3 devices")).toBeInTheDocument(); + }); + + it("offers to revoke an active device and to restore a revoked one", () => { + state.devices = [device(), device({ deviceId: "and-two", status: "REVOKED" })]; + renderPage(); + expect(screen.getByText("Revoke")).toBeInTheDocument(); + expect(screen.getByText("Restore")).toBeInTheDocument(); + }); + + it("hides the revoke control from a read-only viewer", () => { + state.permissions = new Set(["devices:read"]); + state.devices = [device()]; + renderPage(); + expect(screen.queryByText("Revoke")).not.toBeInTheDocument(); + }); + + it("shows the licence but offers no way to edit it", () => { + // The licence is what the customer buys. A company administrator holds "*", + // so an editor here would have let them grant themselves seats. + state.permissions = new Set(["devices:read", "devices:manage"]); + renderPage(); + + expect(screen.getByText("3")).toBeInTheDocument(); // the seat count, read-only + expect(screen.queryByRole("spinbutton")).not.toBeInTheDocument(); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + expect(screen.queryByText("Save")).not.toBeInTheDocument(); + }); + + it("tells the customer who does issue their licence", () => { + renderPage(); + expect(screen.getByText(/issued by Linumic/i)).toBeInTheDocument(); + }); + + it("says so when every seat is taken", () => { + state.license = { ...state.license!, deviceLimit: 2 }; + state.devices = [device(), device({ deviceId: "and-two" })]; + renderPage(); + expect(screen.getByText("2 of 2 devices")).toBeInTheDocument(); + }); + + it("shows an empty state before any device has activated", () => { + renderPage(); + expect(screen.getByText("No devices registered yet")).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/DevicesPage.tsx b/web/src/pages/DevicesPage.tsx new file mode 100644 index 0000000..1344b90 --- /dev/null +++ b/web/src/pages/DevicesPage.tsx @@ -0,0 +1,176 @@ +import { useEffect, useState } from "react"; +import { useDevices, useLicense, useSetDeviceStatus } from "../api/hooks"; +import { useHasPermission } from "../auth/AuthProvider"; +import type { License, LicensedDevice } from "../api/types"; +import { useI18n } from "../i18n/LocaleProvider"; +import { Chip, EmptyState, ErrorState, LoadingState, Toast } from "../ui/components"; + +/** + * Device licensing: how many phones and kiosks may run against this company, + * which ones currently hold a seat, and revoking the ones that should not. + */ +export function DevicesPage() { + const { t, num } = useI18n(); + const can = useHasPermission(); + const canRead = can("devices:read"); + // Revoking a device is legitimate self-service; the licence itself is not. + const canManage = can("devices:manage"); + + const license = useLicense(canRead); + const devices = useDevices(canRead); + const setStatus = useSetDeviceStatus(); + + const [draft, setDraft] = useState(null); + const [toast, setToast] = useState(null); + + // Seed the editable copy once the licence arrives, and whenever it changes + // underneath us — but never while the administrator is mid-edit. + useEffect(() => { + if (license.data && draft === null) setDraft(license.data); + }, [license.data, draft]); + + const flash = (m: string) => { + setToast(m); + window.setTimeout(() => setToast(null), 2500); + }; + + if (!canRead) return ; + + const rows = devices.data ?? []; + const inUse = rows.filter((d) => d.status === "ACTIVE").length; + const limit = license.data?.deviceLimit ?? 0; + const full = limit > 0 && inUse >= limit; + + async function onToggleDevice(device: LicensedDevice) { + try { + await setStatus.mutateAsync({ + deviceId: device.deviceId, + action: device.status === "ACTIVE" ? "revoke" : "restore", + }); + flash(t("dev_saved")); + } catch { + flash(t("common_error")); + } + } + + return ( + <> +
    +

    {t("dev_title")}

    + + {t("dev_seats_used", num(inUse), num(limit))} + +
    + + {/* Licence */} +
    +

    {t("dev_license")}

    + + {license.isLoading ? ( + + ) : license.isError || !draft ? ( + void license.refetch()} /> + ) : ( + <> +
    +
    +
    {t("dev_plan")}
    +
    {t(`dev_plan_${draft.plan.toLowerCase()}`)}
    +
    +
    +
    {t("dev_limit")}
    +
    {num(draft.deviceLimit)}
    +
    +
    +
    {t("dev_status")}
    +
    {t(`dev_status_${draft.status.toLowerCase()}`)}
    +
    +
    +
    {t("dev_expires")}
    +
    + {draft.expiresAt ?? t("dev_expires_never")} +
    +
    +
    +
    {t("dev_enforce")}
    +
    {draft.enforceDevices ? t("common_yes") : t("common_no")}
    +
    +
    + +

    + {t("dev_license_vendor_hint")} +

    + + )} +
    + + {/* Devices */} +
    +

    {t("dev_registered")}

    + + {devices.isLoading ? ( + + ) : devices.isError ? ( + void devices.refetch()} /> + ) : rows.length === 0 ? ( + + ) : ( +
    + + + + + + + + + {canManage && + + + {rows.map((d) => ( + + + + + + + {canManage && ( + + )} + + ))} + +
    {t("dev_device")}{t("dev_type")}{t("dev_employee")}{t("dev_last_seen")}{t("dev_status")}} +
    +
    {d.label ?? d.model ?? t("dev_unnamed")}
    +
    + {d.deviceId} +
    +
    + {d.type === "KIOSK" ? t("dev_type_kiosk") : t("dev_type_mobile")} + {d.appVersion && ( +
    + v{d.appVersion} +
    + )} +
    {d.employeeId ?? "—"}{d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : "—"} + + {d.status === "ACTIVE" ? t("dev_status_active") : t("dev_revoked")} + + + +
    +
    + )} +
    + + {toast && } + + ); +} diff --git a/web/src/pages/EmployeeCodeField.test.tsx b/web/src/pages/EmployeeCodeField.test.tsx new file mode 100644 index 0000000..a23f623 --- /dev/null +++ b/web/src/pages/EmployeeCodeField.test.tsx @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { LocaleProvider } from "../i18n/LocaleProvider"; + +/** + * The employee code field, now that the server numbers people itself. + * + * The rule and the endpoint are covered in the backend. What only the form can + * get wrong is what it puts in the request: an empty string is not the same as + * a missing field. Sent on a create it fails validation; sent on an edit — and + * the edit writes the whole document — it erases the code payroll knows + * somebody by. + */ + +const state = vi.hoisted(() => ({ + created: [] as Record[], + updated: [] as Record[], + list: [] as Record[], + permissions: new Set(["employees:write", "employees:read"]), +})); + +vi.mock("../api/hooks", () => ({ + useEmployees: () => ({ data: { data: state.list }, isLoading: false, isError: false, refetch: vi.fn() }), + useCreateEmployee: () => ({ + mutateAsync: async (body: Record) => { + state.created.push(body); + return { id: "e_new", employeeCode: "E-002" }; + }, + isPending: false, + }), + useUpdateEmployee: () => ({ + mutateAsync: async ({ body }: { body: Record }) => { + state.updated.push(body); + return { id: "e_1" }; + }, + isPending: false, + }), + useEmployeeSalary: () => ({ data: undefined, isLoading: false }), + useSetEmployeeSalary: () => ({ mutateAsync: vi.fn(), isPending: false }), + useResetEmployeePassword: () => ({ mutateAsync: vi.fn(), isPending: false }), + useResetEmployeeFace: () => ({ mutateAsync: vi.fn(), isPending: false }), + // The edit form now carries the document register; it has its own test, so + // keep it inert here. + useEmployeeDocuments: () => ({ data: [], isLoading: false, isError: false }), + useAddDocument: () => ({ mutateAsync: vi.fn(), isPending: false }), + useDeleteDocument: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +vi.mock("../auth/AuthProvider", () => ({ + useAuth: () => ({ me: { branchIds: [], companyId: "c1" } }), + useHasPermission: () => (p: string) => state.permissions.has(p), + useFeatures: () => ({ faceRecognition: false, payroll: true }), +})); + +const { EmployeesPage } = await import("./EmployeesPage"); + +function openAddForm(): void { + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: /افزودن کارمند|Add/ })); +} + +/** The code box, found by its label rather than by position in the grid. */ +function codeInput(): HTMLInputElement { + const label = screen.getByText("کود"); + const field = label.closest(".field") as HTMLElement; + return field.querySelector("input") as HTMLInputElement; +} + +beforeEach(() => { + state.created = []; + state.updated = []; + state.list = []; +}); + +/** Opens the edit form for one employee already in the list. */ +function openEditForm(over: Record = {}): void { + state.list = [ + { + id: "e_1", + companyId: "c1", + employeeCode: "E-007", + firstName: "Ali", + lastName: "Rahimi", + email: "ali@example.com", + phone: null, + branchId: null, + departmentId: null, + positionId: null, + managerId: null, + employmentType: "FULL_TIME", + joinDate: "2026-01-01", + status: "ACTIVE", + avatarUrl: null, + ...over, + }, + ]; + render( + + + , + ); + // The row has an explicit edit button; clicking the name does nothing. + fireEvent.click(screen.getByRole("button", { name: "ویرایش" })); +} + +/** The role selector, by its label. */ +function roleSelect(): HTMLSelectElement { + const field = screen.getByText("نقش").closest(".field") as HTMLElement; + return field.querySelector("select") as HTMLSelectElement; +} + +describe("editing somebody", () => { + it("shows the role they already have", () => { + openEditForm({ role: "TEAM_LEAD" }); + expect(roleSelect().value).toBe("TEAM_LEAD"); + }); + + it("offers to leave an unknown role alone rather than guessing EMPLOYEE", () => { + // Employees created before roles were shown have none on record. Defaulting + // the box to EMPLOYEE would demote a branch manager the first time anybody + // edited their phone number. + openEditForm({ role: null }); + expect(roleSelect().value).toBe(""); + }); + + it("sends no role when it was left unchanged", async () => { + openEditForm({ role: null }); + fireEvent.submit(document.querySelector("form.modal") as HTMLFormElement); + + await waitFor(() => expect(state.updated).toHaveLength(1)); + expect(state.updated[0].role).toBeUndefined(); + }); + + it("sends the role when one is chosen", async () => { + openEditForm({ role: "EMPLOYEE" }); + fireEvent.change(roleSelect(), { target: { value: "BRANCH_MANAGER" } }); + fireEvent.submit(document.querySelector("form.modal") as HTMLFormElement); + + await waitFor(() => expect(state.updated).toHaveLength(1)); + expect(state.updated[0].role).toBe("BRANCH_MANAGER"); + }); +}); + +describe("adding somebody", () => { + it("leaves the code blank and says it is automatic", () => { + openAddForm(); + const input = codeInput(); + + expect(input.value).toBe(""); + // An empty box with no explanation reads as a field the user forgot. + expect(input.placeholder).toBe("خودکار"); + }); + + it("omits the code entirely rather than sending an empty one", async () => { + openAddForm(); + fireEvent.change(screen.getByText("نام").closest(".field")!.querySelector("input")!, { + target: { value: "Zahra" }, + }); + fireEvent.submit(document.querySelector("form.modal") as HTMLFormElement); + + await waitFor(() => expect(state.created).toHaveLength(1)); + // The distinction the server acts on: absent means "number this person", + // "" is a validation failure. + expect(state.created[0].employeeCode).toBeUndefined(); + expect("employeeCode" in state.created[0]).toBe(true); + expect(state.created[0].employeeCode).not.toBe(""); + }); + + it("still sends a code somebody typed", async () => { + openAddForm(); + fireEvent.change(codeInput(), { target: { value: "ACC-77" } }); + fireEvent.submit(document.querySelector("form.modal") as HTMLFormElement); + + await waitFor(() => expect(state.created).toHaveLength(1)); + expect(state.created[0].employeeCode).toBe("ACC-77"); + }); +}); diff --git a/web/src/pages/EmployeeComponents.test.tsx b/web/src/pages/EmployeeComponents.test.tsx new file mode 100644 index 0000000..73cc3bb --- /dev/null +++ b/web/src/pages/EmployeeComponents.test.tsx @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import type { ComponentAssignment, SalaryComponent } from "../api/types"; +import { LocaleProvider } from "../i18n/LocaleProvider"; + +/** + * One employee's exceptions against the company's components. + * + * The load-bearing behaviour is what happens when a row goes back to normal: + * the exception must be deleted, not rewritten to match. An assignment that + * merely copies today's company figure would freeze this person's pay the next + * time that figure changes. + */ + +const setCalls = vi.hoisted(() => ({ current: [] as Array> })); +const clearCalls = vi.hoisted(() => ({ current: [] as Array> })); +const components = vi.hoisted(() => ({ current: [] as SalaryComponent[] })); +const assignments = vi.hoisted(() => ({ current: [] as ComponentAssignment[] })); + +vi.mock("../api/hooks", () => ({ + useSalaryComponents: () => ({ data: components.current, isLoading: false, isError: false }), + useEmployeeComponents: () => ({ data: assignments.current, isLoading: false, isError: false }), + useSetEmployeeComponent: () => ({ + mutateAsync: vi.fn(async (a: Record) => { + setCalls.current.push(a); + }), + isPending: false, + }), + useClearEmployeeComponent: () => ({ + mutateAsync: vi.fn(async (a: Record) => { + clearCalls.current.push(a); + }), + isPending: false, + }), +})); + +const { EmployeeComponents } = await import("./EmployeeComponents"); + +function component(over: Partial = {}): SalaryComponent { + return { + id: "transport", + name: "Transport", + code: "TRANSPORT", + type: "EARNING", + calc: "FIXED", + value: 2000, + taxable: true, + scope: "ALL", + active: true, + ...over, + }; +} + +function renderFor(employeeId: string | null = "e1"): void { + render( + {() as ReactNode}, + ); +} + +describe("one employee's earnings and deductions", () => { + beforeEach(() => { + localStorage.setItem("worktrack.locale", "en"); + setCalls.current = []; + clearCalls.current = []; + components.current = [component()]; + assignments.current = []; + }); + + it("asks for the employee to be saved before assigning anything", () => { + renderFor(null); + expect(screen.getByText(/Save the employee first/i)).toBeInTheDocument(); + }); + + it("sends the administrator to define components when none exist", () => { + components.current = []; + renderFor(); + expect(screen.getByText(/Define them on the Payroll page first/i)).toBeInTheDocument(); + }); + + it("shows a company-wide component as already applying", () => { + renderFor(); + expect(screen.getByRole("switch", { name: /Applies — Transport/ })).toBeChecked(); + }); + + it("treats a component with no scope as company-wide, like the server does", () => { + // An older component, or an older server. Reading it as individual would + // show a live allowance as not applying, and one click would withhold it. + const legacy = component(); + delete (legacy as Partial).scope; + components.current = [legacy]; + renderFor(); + + expect(screen.getByRole("switch", { name: /Applies — Transport/ })).toBeChecked(); + expect(screen.getByText(/everyone gets this/i)).toBeInTheDocument(); + }); + + it("shows an individual component as not applying until it is given", () => { + components.current = [component({ id: "bonus", name: "Bonus", scope: "INDIVIDUAL" })]; + renderFor(); + expect(screen.getByRole("switch", { name: /Applies — Bonus/ })).not.toBeChecked(); + }); + + it("withholds a company-wide component by writing an inactive assignment", async () => { + renderFor(); + await userEvent.click(screen.getByRole("switch", { name: /Applies — Transport/ })); + + expect(setCalls.current).toHaveLength(1); + expect(setCalls.current[0]).toEqual({ + employeeId: "e1", + componentId: "transport", + body: { value: null, active: false }, + }); + }); + + it("deletes the exception rather than rewriting it when a row returns to normal", async () => { + // Writing {active:true} here would look identical today and diverge the + // moment the company changes the amount. + assignments.current = [ + { employeeId: "e1", componentId: "transport", value: null, active: false }, + ]; + renderFor(); + await userEvent.click(screen.getByRole("switch", { name: /Applies — Transport/ })); + + expect(setCalls.current).toHaveLength(0); + expect(clearCalls.current).toEqual([{ employeeId: "e1", componentId: "transport" }]); + }); + + it("keeps the exception when the employee has their own amount", async () => { + // Switching off must not discard the 3500 they are on. + assignments.current = [ + { employeeId: "e1", componentId: "transport", value: 3500, active: true }, + ]; + renderFor(); + await userEvent.click(screen.getByRole("switch", { name: /Applies — Transport/ })); + + expect(clearCalls.current).toHaveLength(0); + expect(setCalls.current[0]).toEqual({ + employeeId: "e1", + componentId: "transport", + body: { value: 3500, active: false }, + }); + }); + + it("saves an amount that differs from the company's", async () => { + renderFor(); + const box = screen.getByLabelText(/Their amount — Transport/); + await userEvent.type(box, "3500"); + await userEvent.tab(); + + expect(setCalls.current[0]).toEqual({ + employeeId: "e1", + componentId: "transport", + body: { value: 3500, active: true }, + }); + }); + + it("treats zero as an amount, not as blank", async () => { + renderFor(); + await userEvent.type(screen.getByLabelText(/Their amount — Transport/), "0"); + await userEvent.tab(); + + expect(setCalls.current[0].body).toEqual({ value: 0, active: true }); + }); + + it("clearing the amount drops the exception entirely", async () => { + assignments.current = [ + { employeeId: "e1", componentId: "transport", value: 3500, active: true }, + ]; + renderFor(); + await userEvent.clear(screen.getByLabelText(/Their amount — Transport/)); + await userEvent.tab(); + + expect(clearCalls.current).toEqual([{ employeeId: "e1", componentId: "transport" }]); + }); + + it("keeps a withheld employee's own amount, and locks the box while it is withheld", async () => { + // Their 3500 is not lost by switching the row off — turning it back on + // restores it rather than dropping them to the company figure. + assignments.current = [ + { employeeId: "e1", componentId: "transport", value: 3500, active: false }, + ]; + renderFor(); + + const box = screen.getByLabelText(/Their amount — Transport/); + expect(box).toBeDisabled(); + expect(box).toHaveValue(3500); + + await userEvent.click(screen.getByRole("switch", { name: /Applies — Transport/ })); + expect(setCalls.current[0].body).toEqual({ value: 3500, active: true }); + }); + + it("shows the company amount as the placeholder so the default is visible", () => { + renderFor(); + expect(screen.getByLabelText(/Their amount — Transport/)).toHaveAttribute( + "placeholder", + "2000", + ); + }); + + it("does not offer an amount for a component the employee does not get", () => { + components.current = [component({ id: "bonus", name: "Bonus", scope: "INDIVIDUAL" })]; + renderFor(); + expect(screen.getByLabelText(/Their amount — Bonus/)).toBeDisabled(); + }); + + it("leaves inactive components out entirely", () => { + components.current = [component(), component({ id: "old", name: "Retired", active: false })]; + renderFor(); + expect(screen.getByText("Transport")).toBeInTheDocument(); + expect(screen.queryByText("Retired")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/EmployeeComponents.tsx b/web/src/pages/EmployeeComponents.tsx new file mode 100644 index 0000000..b78b08d --- /dev/null +++ b/web/src/pages/EmployeeComponents.tsx @@ -0,0 +1,174 @@ +import { useState } from "react"; +import { + useClearEmployeeComponent, + useEmployeeComponents, + useSalaryComponents, + useSetEmployeeComponent, +} from "../api/hooks"; +import type { ComponentAssignment, SalaryComponent } from "../api/types"; +import { useI18n } from "../i18n/LocaleProvider"; +import { EmptyState, LoadingState, Switch } from "../ui/components"; + +/** + * One employee's exceptions against the company's salary components. + * + * The company defines "transport allowance, 2000". Here you say that this + * person gets 3500 of it, or none of it, or that they are one of the few who + * get the site bonus at all. Nothing is stored unless it differs from what the + * component already does — clearing a row deletes the assignment rather than + * writing "same as everyone", so the company-wide figure keeps flowing through + * when it later changes. + */ +export function EmployeeComponents({ employeeId }: { employeeId: string | null }) { + const { t, num } = useI18n(); + const components = useSalaryComponents(); + const assignments = useEmployeeComponents(employeeId); + const setOne = useSetEmployeeComponent(); + const clearOne = useClearEmployeeComponent(); + + // Only the row being edited is tracked locally; everything else reads from + // the server, so two managers editing different people cannot collide. + const [draft, setDraft] = useState>({}); + + if (!employeeId) { + return ( +
    +

    {t("empc_title")}

    +

    {t("empc_save_first")}

    +
    + ); + } + + if (components.isLoading || assignments.isLoading) return ; + + const all = (components.data ?? []).filter((c) => c.active); + if (all.length === 0) { + return ( +
    +

    {t("empc_title")}

    + +
    + ); + } + + const byId = new Map((assignments.data ?? []).map((a) => [a.componentId, a])); + + /** + * What this employee gets today, before any edit in this dialog. + * + * Tested against INDIVIDUAL rather than for ALL so that a component arriving + * without a scope — one written before the field existed, or an older server + * — reads as company-wide, which is what the server does with it. Defaulting + * the other way would show a live allowance as not applying, and one click + * would then withhold it. + */ + function appliesByDefault(c: SalaryComponent): boolean { + return c.scope !== "INDIVIDUAL"; + } + + function applies(c: SalaryComponent, a: ComponentAssignment | undefined): boolean { + return a ? a.active : appliesByDefault(c); + } + + async function toggle(c: SalaryComponent, next: boolean) { + const a = byId.get(c.id); + const backToDefault = next === appliesByDefault(c); + // Returning to the component's own behaviour means removing the exception, + // not recording one that happens to match — otherwise a later change to the + // company figure would not reach this person. + if (backToDefault && a?.value == null) { + await clearOne.mutateAsync({ employeeId: employeeId!, componentId: c.id }); + return; + } + await setOne.mutateAsync({ + employeeId: employeeId!, + componentId: c.id, + body: { value: a?.value ?? null, active: next }, + }); + } + + async function commitAmount(c: SalaryComponent) { + const raw = (draft[c.id] ?? "").trim(); + const a = byId.get(c.id); + setDraft((d) => { + const next = { ...d }; + delete next[c.id]; + return next; + }); + + if (raw === "") { + // Cleared: back to the company amount, which for a row that applies means + // there is no exception left to store. The box is disabled while a row is + // withheld, so this only ever runs on a row that applies. + if (a) await clearOne.mutateAsync({ employeeId: employeeId!, componentId: c.id }); + return; + } + + const value = Number(raw); + if (!Number.isFinite(value) || value < 0) return; + await setOne.mutateAsync({ + employeeId: employeeId!, + componentId: c.id, + body: { value, active: a ? a.active : true }, + }); + } + + return ( +
    +

    {t("empc_title")}

    +

    {t("empc_hint")}

    + + {/* Not a table. This sits inside the employee dialog, which is ~340px + wide whatever the viewport is, and a three-column table there pushed + the amount field — the point of the whole section — out of view at + 44px. A wrapping row survives any container width. */} +
      + {all.map((c) => { + const a = byId.get(c.id); + const on = applies(c, a); + const shown = draft[c.id] ?? (a?.value != null ? String(a.value) : ""); + return ( +
    • +
      +
      {c.name}
      +
      + {t(`comp_type_${c.type.toLowerCase()}`)} ·{" "} + {appliesByDefault(c) ? t("empc_all_note") : t("empc_individual_note")} ·{" "} + {c.calc === "FIXED" ? `${num(c.value)} ${t("comp_afn")}` : `${num(c.value)}٪`} +
      +
      + +
      + + + setDraft({ ...draft, [c.id]: e.target.value })} + onBlur={() => void commitAmount(c)} + aria-label={`${t("empc_amount")} — ${c.name}`} + /> +
      +
    • + ); + })} +
    + +

    + {t("empc_default_hint")} · {t("comp_rerun_hint")} +

    +
    + ); +} diff --git a/web/src/pages/EmployeeDocuments.test.tsx b/web/src/pages/EmployeeDocuments.test.tsx new file mode 100644 index 0000000..bb52027 --- /dev/null +++ b/web/src/pages/EmployeeDocuments.test.tsx @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import type { EmployeeDocument } from "../api/types"; +import { LocaleProvider } from "../i18n/LocaleProvider"; +import { DICTIONARIES } from "../i18n/strings"; + +/** + * The register of papers held for one person. + * + * The expensive case is not a missing document — it is one that quietly ran + * out, so what this component has to get right is the distinction between + * "expired", "expiring", and "does not expire at all". Showing a tazkira as + * "valid" teaches people to ignore the column, which costs more than showing + * nothing. + */ + +const state = vi.hoisted(() => ({ + docs: [] as EmployeeDocument[], + added: [] as Record[], + deleted: [] as string[], +})); + +vi.mock("../api/hooks", () => ({ + useEmployeeDocuments: () => ({ data: state.docs, isLoading: false, isError: false }), + useAddDocument: () => ({ + mutateAsync: async (body: Record) => { + state.added.push(body); + return { id: "d_new" }; + }, + isPending: false, + }), + useDeleteDocument: () => ({ + mutateAsync: async (id: string) => { + state.deleted.push(id); + }, + isPending: false, + }), +})); + +const { EmployeeDocuments } = await import("./EmployeeDocuments"); + +function doc(over: Partial = {}): EmployeeDocument { + return { + id: "d1", + employeeId: "e1", + employeeName: "Ali", + type: "CONTRACT", + number: "C-100", + issuedOn: null, + expiresOn: "2027-01-01", + note: null, + ...over, + }; +} + +function show(): void { + render( + + + , + ); +} + +beforeEach(() => { + state.docs = []; + state.added = []; + state.deleted = []; + vi.setSystemTime(new Date("2026-09-09T10:00:00Z")); +}); + +describe("what the register shows", () => { + it("shows nothing at all without an employee to hang it on", () => { + render( + + + , + ); + expect(screen.queryByText(DICTIONARIES.fa.doc_title)).not.toBeInTheDocument(); + }); + + it("says a document without an expiry does not expire, rather than calling it valid", () => { + // A tazkira shown as "valid" among contracts that really do expire teaches + // people to stop reading the column. + state.docs = [doc({ type: "TAZKIRA", expiresOn: null })]; + show(); + expect(screen.getByText(DICTIONARIES.fa.doc_no_expiry)).toBeInTheDocument(); + }); + + it("marks an expired document as expired", () => { + state.docs = [doc({ expiresOn: "2026-05-01" })]; + show(); + expect(document.querySelector(".chip-negative, .chip.negative")).toBeTruthy(); + }); + + it("warns on one that is close, without calling it expired", () => { + state.docs = [doc({ expiresOn: "2026-09-20" })]; + show(); + const chip = document.querySelector(".chip"); + expect(chip?.className).toMatch(/warning/); + }); + + it("leaves a distant one alone", () => { + state.docs = [doc({ expiresOn: "2028-01-01" })]; + show(); + expect(document.querySelector(".chip")?.className).toMatch(/positive/); + }); + + it("says so when there is nothing on file", () => { + show(); + expect(screen.getByText(DICTIONARIES.fa.doc_empty)).toBeInTheDocument(); + }); + + it("removes one", async () => { + state.docs = [doc({ id: "d7" })]; + show(); + fireEvent.click(screen.getByRole("button", { name: DICTIONARIES.fa.doc_delete })); + await waitFor(() => expect(state.deleted).toEqual(["d7"])); + }); +}); + +describe("adding one", () => { + function openForm(): void { + show(); + fireEvent.click(screen.getByRole("button", { name: DICTIONARIES.fa.doc_add })); + } + + it("sends an empty expiry as null, meaning it does not expire", async () => { + // "" would be an invalid date and the register has no use for a date + // somebody meant to fill in later. + openForm(); + fireEvent.click(screen.getByRole("button", { name: DICTIONARIES.fa.common_save })); + + await waitFor(() => expect(state.added).toHaveLength(1)); + expect(state.added[0].expiresOn).toBeNull(); + expect(state.added[0].employeeId).toBe("e1"); + }); + + it("sends the date when one is given", async () => { + openForm(); + const field = screen.getByText(DICTIONARIES.fa.doc_expires_on).closest(".field") as HTMLElement; + fireEvent.change(field.querySelector("input")!, { target: { value: "2027-03-01" } }); + fireEvent.click(screen.getByRole("button", { name: DICTIONARIES.fa.common_save })); + + await waitFor(() => expect(state.added).toHaveLength(1)); + expect(state.added[0].expiresOn).toBe("2027-03-01"); + }); + + it("saves with a button that does not submit the employee form around it", () => { + // This sits INSIDE the employee form. A submit button here would save the + // employee instead of the document. + openForm(); + const save = screen.getByRole("button", { name: DICTIONARIES.fa.common_save }); + expect(save.getAttribute("type")).toBe("button"); + }); +}); diff --git a/web/src/pages/EmployeeDocuments.tsx b/web/src/pages/EmployeeDocuments.tsx new file mode 100644 index 0000000..e9857d0 --- /dev/null +++ b/web/src/pages/EmployeeDocuments.tsx @@ -0,0 +1,201 @@ +import { type FormEvent, useState } from "react"; +import { + useAddDocument, + useDeleteDocument, + useEmployeeDocuments, +} from "../api/hooks"; +import { ApiError } from "../api/client"; +import type { DocumentType, EmployeeDocument } from "../api/types"; +import { useI18n } from "../i18n/LocaleProvider"; +import { Chip } from "../ui/components"; + +const TYPES: DocumentType[] = [ + "TAZKIRA", + "CONTRACT", + "WORK_PERMIT", + "HEALTH_CERTIFICATE", + "LICENCE", + "OTHER", +]; + +/** + * The papers held for one person, inside their record. + * + * A register, not a filing cabinet: what the document is, its number, and when + * it stops being valid. The scan itself needs storage and an access decision + * of its own, and the expiry warning is worth having long before the + * photograph is. + */ +export function EmployeeDocuments({ employeeId }: { employeeId: string | null }) { + const { t, num, shamsi } = useI18n(); + const documents = useEmployeeDocuments(employeeId); + const remove = useDeleteDocument(); + const [adding, setAdding] = useState(false); + + if (!employeeId) return null; + const rows = documents.data ?? []; + + return ( +
    + + + {rows.length === 0 ? ( +

    {t("doc_empty")}

    + ) : ( +
      + {rows.map((d) => ( +
    • + {t(`doc_type_${d.type.toLowerCase()}`)} + {d.number && {num(d.number)}} + + +
    • + ))} +
    + )} + + {adding ? ( + setAdding(false)} + /> + ) : ( + + )} +
    + ); + + function ExpiryChip({ document }: { document: EmployeeDocument }) { + if (!document.expiresOn) { + // Not the same as valid. A register that shows every permanent document + // as "valid" teaches people to ignore the column. + return {t("doc_no_expiry")}; + } + const days = daysUntil(document.expiresOn); + const tone = days < 0 ? "negative" : days <= 30 ? "warning" : "positive"; + return ( + + {days < 0 + ? t("doc_expired", shamsi(document.expiresOn, { withYear: true })) + : t("doc_expires", shamsi(document.expiresOn, { withYear: true }))} + + ); + } +} + +function DocumentForm({ + employeeId, + onDone, +}: { + employeeId: string; + onDone: () => void; +}) { + const { t } = useI18n(); + const add = useAddDocument(); + const [error, setError] = useState(null); + const [form, setForm] = useState({ + type: "CONTRACT" as DocumentType, + number: "", + expiresOn: "", + }); + + async function onSubmit(e: FormEvent): Promise { + // This sits inside the employee form, so a submit here must not submit + // that one. + e.preventDefault(); + e.stopPropagation(); + setError(null); + try { + await add.mutateAsync({ + employeeId, + type: form.type, + number: form.number.trim() || null, + // Empty means "does not expire" rather than "unknown" — the register + // has no use for a date somebody meant to fill in later. + expiresOn: form.expiresOn || null, + }); + onDone(); + } catch (err) { + setError(err instanceof ApiError ? err.message : t("common_error")); + } + } + + return ( +
    +
    +
    + + +
    +
    + + setForm((f) => ({ ...f, number: e.target.value }))} + /> +
    +
    + +
    + + setForm((f) => ({ ...f, expiresOn: e.target.value }))} + /> + {t("doc_expires_hint")} +
    + + {error &&

    {error}

    } + +
    + + +
    +
    + ); +} + +function daysUntil(iso: string): number { + const to = Date.parse(`${iso}T00:00:00Z`); + const today = new Date(); + const from = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()); + return Math.round((to - from) / 86_400_000); +} diff --git a/web/src/pages/EmployeesPage.tsx b/web/src/pages/EmployeesPage.tsx new file mode 100644 index 0000000..d86cbe9 --- /dev/null +++ b/web/src/pages/EmployeesPage.tsx @@ -0,0 +1,627 @@ +import { type FormEvent, useEffect, useMemo, useState } from "react"; +import { + useCreateEmployee, + useEmployeeSalary, + useSetEmployeeSalary, + useEmployees, + useResetEmployeeFace, + useResetEmployeePassword, + useUpdateEmployee, +} from "../api/hooks"; +import { ApiError } from "../api/client"; +import type { + AssignableRole, + PayModel, + Employee, + EmployeeCreated, + EmployeeStatus, + EmploymentType, +} from "../api/types"; +import { useAuth, useFeatures, useHasPermission } from "../auth/AuthProvider"; +import { EmployeeComponents } from "./EmployeeComponents"; +import { EmployeeDocuments } from "./EmployeeDocuments"; +import { useI18n } from "../i18n/LocaleProvider"; +import { Chip, EmptyState, ErrorState, LoadingState, StatusChip, Toast } from "../ui/components"; + +const EMPLOYMENT_TYPES: EmploymentType[] = ["FULL_TIME", "PART_TIME", "CONTRACT", "INTERN"]; +const STATUSES: EmployeeStatus[] = ["ACTIVE", "ON_LEAVE", "SUSPENDED", "EXITED"]; +const ROLES: AssignableRole[] = [ + "EMPLOYEE", + "TEAM_LEAD", + "BRANCH_MANAGER", + "HR_ADMIN", + "PAYROLL_ADMIN", + "AUDITOR", +]; + +export function EmployeesPage() { + const { t, num, shamsi } = useI18n(); + const can = useHasPermission(); + const [search, setSearch] = useState(""); + const [showForm, setShowForm] = useState(false); + const [editing, setEditing] = useState(null); + const [toast, setToast] = useState(null); + const [credentials, setCredentials] = useState<{ email: string; password: string } | null>(null); + + const features = useFeatures(); + const resetFace = useResetEmployeeFace(); + const showFace = features.faceRecognition; + + const employees = useEmployees({}); + + async function onResetFace(id: string) { + try { + await resetFace.mutateAsync(id); + setToast(t("emp_face_reset_done")); + } catch { + setToast(t("common_error")); + } + window.setTimeout(() => setToast(null), 2500); + } + + const filtered = useMemo(() => { + const rows = employees.data?.data ?? []; + const q = search.trim().toLowerCase(); + if (!q) return rows; + return rows.filter( + (e) => + `${e.firstName} ${e.lastName}`.toLowerCase().includes(q) || + e.employeeCode.toLowerCase().includes(q) || + e.email.toLowerCase().includes(q), + ); + }, [employees.data, search]); + + return ( + <> +
    +

    {t("emp_title")}

    + {can("employees:write") && ( + + )} +
    + +
    + setSearch(e.target.value)} + /> +
    + + {employees.isLoading ? ( + + ) : employees.isError ? ( + void employees.refetch()} /> + ) : filtered.length === 0 ? ( + + ) : ( +
    + + + + + + + + + + {showFace && } + {can("employees:write") && + + + {filtered.map((e: Employee) => ( + + + + + + + + {showFace && ( + + )} + {can("employees:write") && ( + + )} + + ))} + +
    {t("emp_code")}{t("emp_name")}{t("emp_email")}{t("emp_type")}{t("emp_join_date")}{t("emp_status")}{t("emp_face")}} +
    {num(e.employeeCode)} + {e.firstName} {e.lastName} + {e.email}{t(`type_${e.employmentType.toLowerCase()}`)}{shamsi(e.joinDate, { withYear: true })} + + +
    + + {e.faceEnrolled ? t("emp_face_enrolled") : t("emp_face_not_enrolled")} + + {e.faceEnrolled && can("employees:write") && ( + + )} +
    +
    + +
    +
    + )} + + {(showForm || editing) && ( + { + setShowForm(false); + setEditing(null); + }} + onSaved={(created) => { + setShowForm(false); + setEditing(null); + if (created?.tempPassword) { + setCredentials({ email: created.email, password: created.tempPassword }); + } else { + setToast(t(created ? "emp_created" : "emp_updated")); + window.setTimeout(() => setToast(null), 2500); + } + }} + onPasswordReset={(email, password) => { + setEditing(null); + setCredentials({ email, password }); + }} + /> + )} + {credentials && ( + setCredentials(null)} /> + )} + {toast && } + + ); +} + +function CredentialsDialog({ + credentials, + onClose, +}: { + credentials: { email: string; password: string }; + onClose: () => void; +}) { + const { t } = useI18n(); + const [copied, setCopied] = useState(false); + + function copy() { + void navigator.clipboard + .writeText(`${credentials.email} / ${credentials.password}`) + .then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }); + } + + return ( +
    +
    e.stopPropagation()} style={{ maxWidth: 420 }}> +

    {t("emp_credentials_title")}

    +

    {t("emp_credentials_hint")}

    +
    +
    +
    {t("emp_credentials_email")}
    +
    {credentials.email}
    +
    +
    +
    {t("emp_credentials_password")}
    +
    {credentials.password}
    +
    +
    +
    + + +
    +
    +
    + ); +} + +function EmployeeForm({ + onClose, + onSaved, + onPasswordReset, + employee, +}: { + onClose: () => void; + onSaved: (created: EmployeeCreated | null) => void; + onPasswordReset: (email: string, password: string) => void; + employee?: Employee; +}) { + const { t } = useI18n(); + const { me } = useAuth(); + const can = useHasPermission(); + const isEdit = !!employee; + const create = useCreateEmployee(); + const update = useUpdateEmployee(); + // Compensation belongs to whoever runs payroll, so the field only appears + // for them — anyone else would get a 403 on save. + const canSetPay = can("payroll:run"); + const existingSalary = useEmployeeSalary(canSetPay && employee ? employee.id : null); + const setSalary = useSetEmployeeSalary(); + const resetPassword = useResetEmployeePassword(); + const [fieldErrors, setFieldErrors] = useState>({}); + const [formError, setFormError] = useState(null); + + async function onResetPassword() { + if (!employee) return; + setFormError(null); + setFieldErrors({}); + try { + const { tempPassword } = await resetPassword.mutateAsync({ + id: employee.id, + password: form.initialPassword || undefined, + }); + onPasswordReset(employee.email, tempPassword); + } catch (err) { + if (err instanceof ApiError && err.fieldErrors.password) { + setFieldErrors(err.fieldErrors); + } else { + setFormError(t("common_error")); + } + } + } + const [form, setForm] = useState({ + employeeCode: employee?.employeeCode ?? "", + firstName: employee?.firstName ?? "", + lastName: employee?.lastName ?? "", + email: employee?.email ?? "", + phone: employee?.phone ?? "", + branchId: employee?.branchId ?? me?.branchIds[0] ?? "", + employmentType: employee?.employmentType ?? ("FULL_TIME" as EmploymentType), + joinDate: employee?.joinDate ?? isoToday(), + status: employee?.status ?? ("ACTIVE" as EmployeeStatus), + // On an edit this starts as whatever the employee already is, and "" when + // that is unknown — an employee created before roles were shown. Sending + // "" omits the field, which the server reads as "leave the role alone", + // so an ordinary edit can never demote somebody by accident. + role: (isEdit ? ((employee?.role as AssignableRole | undefined) ?? "") : "EMPLOYEE") as + | AssignableRole + | "", + createLogin: !isEdit, + initialPassword: "", + basicAmount: "", + payModel: "MONTHLY" as PayModel, + }); + + function set(key: K, value: (typeof form)[K]) { + setForm((f) => ({ ...f, [key]: value })); + } + + // The salary lives in its own document, so it arrives after the form mounts. + // Only seed the field while it is untouched, or typing would be overwritten. + useEffect(() => { + const amount = existingSalary.data?.basicAmount; + if (amount !== undefined) { + setForm((f) => + f.basicAmount === "" + ? { + ...f, + basicAmount: String(amount), + payModel: existingSalary.data?.payModel ?? "MONTHLY", + } + : f, + ); + } + }, [existingSalary.data]); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + setFieldErrors({}); + setFormError(null); + const body = { + // Left out when blank, which is what asks the server to number this + // person. Sending "" would just fail validation, and on an edit it is + // the difference between "leave the code alone" and "erase it". + employeeCode: form.employeeCode.trim() || undefined, + firstName: form.firstName, + lastName: form.lastName, + email: form.email, + phone: form.phone || null, + branchId: form.branchId || null, + employmentType: form.employmentType, + joinDate: form.joinDate, + status: form.status, + role: form.role || undefined, + createLogin: form.createLogin, + initialPassword: form.initialPassword || undefined, + }; + try { + // The employee record is saved first: the salary hangs off its id, and a + // new employee has none until the create returns. + const savedId = isEdit && employee ? employee.id : null; + let created: EmployeeCreated | null = null; + if (savedId) { + await update.mutateAsync({ id: savedId, body }); + } else { + created = await create.mutateAsync(body); + } + + const targetId = savedId ?? created?.id; + const amount = form.basicAmount.trim(); + if (canSetPay && targetId && amount !== "") { + const basicAmount = Number(amount); + if (Number.isFinite(basicAmount) && basicAmount >= 0) { + await setSalary.mutateAsync({ + id: targetId, + body: { basicAmount, payModel: form.payModel, effectiveFrom: form.joinDate }, + }); + } + } + onSaved(created); + } catch (err) { + if (err instanceof ApiError) { + setFieldErrors(err.fieldErrors); + // Duplicate-email and other business errors carry no field map. + if (!Object.keys(err.fieldErrors).length) { + setFormError(err.code === "CONFLICT" ? t("signup_email_exists") : err.message); + } + } else { + setFormError(t("common_error")); + } + } + } + + return ( +
    +
    e.stopPropagation()} onSubmit={onSubmit}> +

    {isEdit ? t("emp_edit_title") : t("emp_add")}

    +
    + {/* Blank on a new hire: the server assigns the next code, and the + placeholder says so rather than leaving an empty box that looks + like something the user forgot. Still typeable — a company with + its own payroll numbers keeps using them. */} + set("employeeCode", v)} + error={fieldErrors.employeeCode} + placeholder={isEdit ? undefined : t("emp_code_auto")} + dir="ltr" + /> + set("phone", v)} dir="ltr" /> + set("firstName", v)} error={fieldErrors.firstName} /> + set("lastName", v)} error={fieldErrors.lastName} /> +
    + set("email", v)} dir="ltr" error={fieldErrors.email} /> +
    +
    + + +
    +
    + + set("joinDate", e.target.value)} /> +
    +
    + + {canSetPay && ( +
    + {/* Chosen BEFORE the amount, because it decides what the amount + means: 30,000 is a monthly salary or an absurd daily wage, and + the label below changes to say which. */} + + + + {t(`pay_model_hint_${form.payModel.toLowerCase()}`)} + +
    + )} + + {canSetPay && ( +
    + + set("basicAmount", e.target.value)} + placeholder={t("emp_basic_salary_ph")} + /> + {t("emp_basic_salary_hint")} +
    + )} + + {/* Allowances and deductions for this person. Only for an employee who + already exists — an assignment needs an id to point at. */} + {canSetPay && isEdit && } + + {/* The papers held for this person. Edit only: a document needs an + employee id to hang off. */} + {isEdit && } + + {isEdit ? ( + <> +
    + + + {fieldErrors.role && {fieldErrors.role}} +
    +
    + + +
    +
    + + set("initialPassword", e.target.value)} + /> + {fieldErrors.password && {fieldErrors.password}} + +
    + + ) : ( + <> +
    + + +
    + + + + {form.createLogin && ( + set("initialPassword", v)} + dir="ltr" + error={fieldErrors.initialPassword} + /> + )} + + )} + + {formError &&
    {formError}
    } + +
    + + +
    + +
    + ); +} + +function Text({ + label, + value, + onChange, + error, + dir, + placeholder, +}: { + label: string; + value: string; + onChange: (v: string) => void; + error?: string; + dir?: "ltr" | "rtl"; + placeholder?: string; +}) { + return ( +
    + + onChange(e.target.value)} + /> + {error && {error}} +
    + ); +} + +function isoToday(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( + d.getDate(), + ).padStart(2, "0")}`; +} diff --git a/web/src/pages/FinancePage.tsx b/web/src/pages/FinancePage.tsx new file mode 100644 index 0000000..504a08d --- /dev/null +++ b/web/src/pages/FinancePage.tsx @@ -0,0 +1,522 @@ +import { useState } from "react"; +import { + useAccounts, + useCreateExpense, + useCreateJournalEntry, + useDecideExpense, + useExpenses, + useFinanceOverview, + useJournal, + useTrialBalance, +} from "../api/hooks"; +import type { Expense, ExpenseCategory } from "../api/types"; +import { useHasPermission } from "../auth/AuthProvider"; +import { useI18n } from "../i18n/LocaleProvider"; +import { Chip, EmptyState, ErrorState, LoadingState, Toast } from "../ui/components"; + +type Tab = "overview" | "expenses" | "ledger"; + +const EXPENSE_CATEGORIES: ExpenseCategory[] = [ + "rent", + "utilities", + "supplies", + "travel", + "services", + "other", +]; + +export function FinancePage() { + const { t } = useI18n(); + const [tab, setTab] = useState("overview"); + + const tabs: { key: Tab; label: string }[] = [ + { key: "overview", label: t("fin_tab_overview") }, + { key: "expenses", label: t("fin_tab_expenses") }, + { key: "ledger", label: t("fin_tab_ledger") }, + ]; + + return ( + <> +
    +

    {t("fin_title")}

    +
    + {tabs.map((tb) => ( + + ))} +
    +
    + + {tab === "overview" && } + {tab === "expenses" && } + {tab === "ledger" && } + + ); +} + +// ------------------------------------------------------------------- overview + +function OverviewTab() { + const { t, num, locale } = useI18n(); + const overview = useFinanceOverview(); + + if (overview.isLoading) return ; + if (overview.isError) + return void overview.refetch()} />; + const o = overview.data!; + const cur = o.currency; + + return ( + <> +
    + = 0 ? undefined : "red"} /> + + + + + 0 ? "amber" : undefined} /> +
    + +
    +
    +

    {t("fin_position")}

    + + + + + +
    + +
    +

    {t("fin_trend")}

    + {o.trend.length === 0 ? ( + + ) : ( + + + + + + + + + + + {o.trend.map((p) => ( + + + + + + + ))} + +
    {t("fin_month")}{t("fin_income")}{t("fin_expense")}{t("fin_net")}
    {p.month}{money(p.income, cur, num)}{money(p.expense, cur, num)}= 0 ? "var(--green)" : "var(--red)" }}> + {money(p.net, cur, num)} +
    + )} +
    +
    + + ); +} + +function Kpi({ label, value, accent }: { label: string; value: string; accent?: "red" | "amber" | "orange" }) { + return ( +
    +
    {value}
    +
    {label}
    +
    + ); +} + +function PositionRow({ label, value, strong }: { label: string; value: string; strong?: boolean }) { + return ( +
    + {label} + {value} +
    + ); +} + +// ------------------------------------------------------------------- expenses + +function ExpensesTab() { + const { t, num } = useI18n(); + const can = useHasPermission(); + const expenses = useExpenses(); + const decide = useDecideExpense(); + const [adding, setAdding] = useState(false); + const [toast, setToast] = useState(null); + + function flash(msg: string) { + setToast(msg); + window.setTimeout(() => setToast(null), 2400); + } + + async function onDecide(id: string, action: "APPROVE" | "REJECT" | "PAY") { + try { + await decide.mutateAsync({ id, action }); + flash(t("fin_expense_updated")); + } catch { + flash(t("common_error")); + } + } + + return ( + <> + {can("expenses:write") && ( +
    + +
    + )} + + {expenses.isLoading ? ( + + ) : expenses.isError ? ( + void expenses.refetch()} /> + ) : (expenses.data?.length ?? 0) === 0 ? ( + + ) : ( +
    + + + + + + + + + + + + {expenses.data!.map((e) => ( + + + + + + + + + ))} + +
    {t("fin_date")}{t("fin_vendor")}{t("fin_category")}{t("fin_amount")}{t("fin_status")} +
    {e.date}{e.vendor}{t(`fin_cat_${e.category}`)}{money(e.amount, e.currency, num)} + +
    +
    + )} + + {adding && setAdding(false)} onSaved={() => { setAdding(false); flash(t("fin_expense_added")); }} />} + {toast && } + + ); +} + +function ExpenseActions({ + expense, + can, + onDecide, + busy, +}: { + expense: Expense; + can: boolean; + onDecide: (id: string, action: "APPROVE" | "REJECT" | "PAY") => void; + busy: boolean; +}) { + const { t } = useI18n(); + if (!can) return null; + return ( +
    + {expense.status === "DRAFT" && ( + <> + + + + )} + {expense.status === "APPROVED" && ( + + )} +
    + ); +} + +function ExpenseStatusChip({ status }: { status: Expense["status"] }) { + const { t } = useI18n(); + const map = { + DRAFT: "warning", + APPROVED: "positive", + PAID: "neutral", + REJECTED: "negative", + } as const; + return {t(`fin_status_${status.toLowerCase()}`)}; +} + +function AddExpenseModal({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) { + const { t } = useI18n(); + const create = useCreateExpense(); + const [category, setCategory] = useState("rent"); + const [vendor, setVendor] = useState(""); + const [amount, setAmount] = useState(""); + const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); + const [description, setDescription] = useState(""); + const [error, setError] = useState(null); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + const value = Number(amount); + if (!vendor.trim() || !(value > 0)) { + setError(t("fin_form_invalid")); + return; + } + try { + await create.mutateAsync({ category, vendor: vendor.trim(), amount: value, date, description }); + onSaved(); + } catch { + setError(t("common_error")); + } + } + + return ( +
    +
    e.stopPropagation()} onSubmit={submit}> +

    {t("fin_add_expense")}

    +
    + + setVendor(e.target.value)} required /> +
    +
    +
    + + +
    +
    + + setAmount(e.target.value)} required /> +
    +
    +
    + + setDate(e.target.value)} required /> +
    +
    + + setDescription(e.target.value)} /> +
    + {error &&
    {error}
    } +
    + + +
    +
    +
    + ); +} + +// --------------------------------------------------------------------- ledger + +function LedgerTab() { + const { t, num } = useI18n(); + const can = useHasPermission(); + const trial = useTrialBalance(); + const journal = useJournal(); + const [adding, setAdding] = useState(false); + const [toast, setToast] = useState(null); + + return ( + <> + {can("ledger:write") && ( +
    + +
    + )} + +
    +
    +

    {t("fin_trial_balance")}

    + {trial.isLoading ? ( + + ) : trial.isError ? ( + void trial.refetch()} /> + ) : (trial.data?.rows.length ?? 0) === 0 ? ( + + ) : ( + + + + + + + + + + {trial.data!.rows.map((r) => ( + + + + + + ))} + + + + + + +
    {t("fin_account")}{t("fin_debit")}{t("fin_credit")}
    {r.code} · {r.name}{r.debit ? num(fmt(r.debit)) : "—"}{r.credit ? num(fmt(r.credit)) : "—"}
    {t("fin_total")}{num(fmt(trial.data!.totalDebit))}{num(fmt(trial.data!.totalCredit))}
    + )} +
    + +
    +

    {t("fin_journal")}

    + {journal.isLoading ? ( + + ) : journal.isError ? ( + void journal.refetch()} /> + ) : (journal.data?.length ?? 0) === 0 ? ( + + ) : ( + + + + + + + + + + {journal.data!.map((j) => ( + + + + + + ))} + +
    {t("fin_date")}{t("fin_memo")}{t("fin_amount")}
    {j.date}{j.memo}{num(fmt(j.totalDebit))}
    + )} +
    +
    + + {adding && setAdding(false)} onSaved={() => { setAdding(false); setToast(t("fin_journal_added")); window.setTimeout(() => setToast(null), 2400); }} />} + {toast && } + + ); +} + +function AddJournalModal({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) { + const { t } = useI18n(); + const accounts = useAccounts(); + const create = useCreateJournalEntry(); + const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); + const [memo, setMemo] = useState(""); + const [debitCode, setDebitCode] = useState(""); + const [creditCode, setCreditCode] = useState(""); + const [amount, setAmount] = useState(""); + const [error, setError] = useState(null); + + const opts = accounts.data ?? []; + + async function submit(e: React.FormEvent) { + e.preventDefault(); + const value = Number(amount); + if (!memo.trim() || !debitCode || !creditCode || debitCode === creditCode || !(value > 0)) { + setError(t("fin_journal_invalid")); + return; + } + try { + await create.mutateAsync({ + date, + memo: memo.trim(), + lines: [ + { accountCode: debitCode, accountName: "", debit: value, credit: 0 }, + { accountCode: creditCode, accountName: "", debit: 0, credit: value }, + ], + }); + onSaved(); + } catch { + setError(t("common_error")); + } + } + + return ( +
    +
    e.stopPropagation()} onSubmit={submit}> +

    {t("fin_add_journal")}

    +
    + + setMemo(e.target.value)} required /> +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + setAmount(e.target.value)} required /> +
    +
    + + setDate(e.target.value)} required /> +
    +
    + {error &&
    {error}
    } +
    + + +
    +
    +
    + ); +} + +// --------------------------------------------------------------------- helpers + +function fmt(n: number): string { + return n.toLocaleString("en-US", { maximumFractionDigits: 0 }); +} + +function money(n: number, currency: string, num: (v: string | number) => string): string { + return `${num(fmt(n))} ${currency}`; +} diff --git a/web/src/pages/HolidaysCard.test.tsx b/web/src/pages/HolidaysCard.test.tsx new file mode 100644 index 0000000..41e69a8 --- /dev/null +++ b/web/src/pages/HolidaysCard.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { Holiday } from "../api/types"; +import { LocaleProvider } from "../i18n/LocaleProvider"; + +const state = vi.hoisted(() => ({ + holidays: [] as Holiday[], + permissions: new Set(), +})); + +vi.mock("../api/hooks", () => ({ + useHolidays: () => ({ data: state.holidays, isLoading: false, isError: false, refetch: vi.fn() }), + useSaveHoliday: () => ({ mutateAsync: vi.fn(), isPending: false }), + useDeleteHoliday: () => ({ mutateAsync: vi.fn(), isPending: false }), + useSeedHolidays: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +vi.mock("../auth/AuthProvider", () => ({ + useHasPermission: () => (p: string) => state.permissions.has(p), +})); + +const { HolidaysCard } = await import("./HolidaysCard"); + +function holiday(over: Partial = {}): Holiday { + return { + date: "2026-08-19", + name: "روز استقلال", + nameEn: "Independence Day", + paid: true, + source: "SOLAR_RECURRING", + ...over, + }; +} + +function renderCard() { + return render( + + + , + ); +} + +beforeEach(() => { + localStorage.setItem("worktrack.locale", "en"); + state.holidays = []; + state.permissions = new Set(["calendar:write"]); +}); + +describe("working calendar card", () => { + it("shows a holiday with both its Shamsi and Gregorian date", () => { + state.holidays = [holiday()]; + renderCard(); + // 28 Asad 1405 is 19 August 2026. The month keeps its Dari name in every + // locale — the Solar Hijri months have no English names, only + // transliterations, and an Afghan reader knows them as اسد. + expect(screen.getByText(/28 اسد 1405/)).toBeInTheDocument(); + expect(screen.getByText("2026-08-19")).toBeInTheDocument(); + }); + + it("marks a generated holiday so it is not mistaken for a manual entry", () => { + state.holidays = [holiday()]; + renderCard(); + expect(screen.getByText("Generated")).toBeInTheDocument(); + }); + + it("does not mark a manually added one", () => { + state.holidays = [holiday({ source: "MANUAL", name: "عید فطر" })]; + renderCard(); + expect(screen.queryByText("Generated")).not.toBeInTheDocument(); + }); + + it("distinguishes paid from unpaid closures", () => { + state.holidays = [ + holiday({ date: "2026-08-19", paid: true }), + holiday({ date: "2026-08-20", paid: false, source: "MANUAL" }), + ]; + renderCard(); + expect(screen.getByText("Unpaid")).toBeInTheDocument(); + }); + + it("hides every control from someone who may only look", () => { + state.permissions = new Set(); + state.holidays = [holiday()]; + renderCard(); + expect(screen.queryByText("Add")).not.toBeInTheDocument(); + expect(screen.queryByText("Remove")).not.toBeInTheDocument(); + expect(screen.queryByText(/Generate this year/)).not.toBeInTheDocument(); + }); + + it("still shows the holidays to that person", () => { + state.permissions = new Set(); + state.holidays = [holiday()]; + renderCard(); + // An employee needs to know the office is shut. + expect(screen.getByText("روز استقلال")).toBeInTheDocument(); + }); + + it("says so when the year has no holidays yet", () => { + renderCard(); + expect(screen.getByText("No holidays recorded for this year")).toBeInTheDocument(); + }); + + it("explains why the lunar holidays are not generated", () => { + renderCard(); + expect(screen.getByText(/announced by sighting/)).toBeInTheDocument(); + }); + + it("leaves out a holiday from a different year", () => { + state.holidays = [holiday({ date: "2027-08-20" })]; + renderCard(); + expect(screen.getByText("No holidays recorded for this year")).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/HolidaysCard.tsx b/web/src/pages/HolidaysCard.tsx new file mode 100644 index 0000000..ad3c3ba --- /dev/null +++ b/web/src/pages/HolidaysCard.tsx @@ -0,0 +1,194 @@ +import { useState } from "react"; +import { useDeleteHoliday, useHolidays, useSaveHoliday, useSeedHolidays } from "../api/hooks"; +import { useHasPermission } from "../auth/AuthProvider"; +import { useI18n } from "../i18n/LocaleProvider"; +import { shamsiToday, toShamsi } from "../shamsi/solarHijri"; +import { Chip, EmptyState, ErrorState, LoadingState, Switch, Toast } from "../ui/components"; + +const SHAMSI_MONTHS = [ + "حمل", "ثور", "جوزا", "سرطان", "اسد", "سنبله", + "میزان", "عقرب", "قوس", "جدی", "دلو", "حوت", +]; + +/** + * The company's working calendar. + * + * This is what stops a closed office reading as absence. Weekends come from the + * policy above; the days listed here are the ones the company closes on top of + * that, and payroll excludes both. + */ +export function HolidaysCard() { + const { t, num } = useI18n(); + const can = useHasPermission(); + const canManage = can("calendar:write"); + + const today = shamsiToday(); + const [year, setYear] = useState(today.year); + // A Shamsi year runs from ~21 March to ~20 March; ask the server for a + // generous Gregorian window and let it filter. + const from = `${year + 620}-03-01`; + const to = `${year + 622}-04-01`; + + const holidays = useHolidays(from, to); + const save = useSaveHoliday(); + const remove = useDeleteHoliday(); + const seed = useSeedHolidays(); + + const [date, setDate] = useState(""); + const [name, setName] = useState(""); + const [paid, setPaid] = useState(true); + const [toast, setToast] = useState(null); + + const flash = (m: string) => { + setToast(m); + window.setTimeout(() => setToast(null), 2500); + }; + + async function onAdd() { + if (!date || !name.trim()) return; + try { + await save.mutateAsync({ date, name: name.trim(), paid }); + setDate(""); + setName(""); + setPaid(true); + flash(t("hol_saved")); + } catch { + flash(t("common_error")); + } + } + + async function onSeed() { + try { + const r = await seed.mutateAsync(year); + flash(r.added > 0 ? t("hol_seeded", num(r.added)) : t("hol_seed_none")); + } catch { + flash(t("common_error")); + } + } + + const rows = (holidays.data ?? []).filter((h) => { + const s = toShamsi(h.date); + return s.year === year; + }); + + return ( +
    +

    {t("hol_title")}

    +

    {t("hol_hint")}

    + +
    + + {canManage && ( + + )} +
    + + {canManage && ( +
    + + + + +
    + )} + + {holidays.isLoading ? ( + + ) : holidays.isError ? ( + void holidays.refetch()} /> + ) : rows.length === 0 ? ( + + ) : ( +
    + + + + + + + {canManage && + + + {rows.map((h) => { + const s = toShamsi(h.date); + return ( + + + + + {canManage && ( + + )} + + ); + })} + +
    {t("hol_date")}{t("hol_name")}{t("hol_paid")}} +
    +
    + {num(s.day)} {SHAMSI_MONTHS[s.month - 1]} {num(s.year)} +
    +
    + {h.date} +
    +
    +
    {h.name}
    + {h.source === "SOLAR_RECURRING" && ( + {t("hol_generated")} + )} +
    + + {h.paid ? t("hol_paid_yes") : t("hol_paid_no")} + + + +
    +
    + )} + +

    + {t("hol_lunar_note")} +

    + + {toast && } +
    + ); +} diff --git a/web/src/pages/KioskPage.tsx b/web/src/pages/KioskPage.tsx new file mode 100644 index 0000000..d2e0d99 --- /dev/null +++ b/web/src/pages/KioskPage.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import QRCode from "qrcode"; +import { useKioskToken } from "../api/hooks"; +import { useAuth } from "../auth/AuthProvider"; +import { useI18n } from "../i18n/LocaleProvider"; + +/** + * Full-screen kiosk mode for a shared entrance tablet. Shows a QR that rotates + * every 30s (minted server-side); employees scan it in the app to punch. No + * chrome — it's meant to run unattended on a wall-mounted screen. + */ +export function KioskPage() { + const { t, num } = useI18n(); + const { me, status, signOut } = useAuth(); + const navigate = useNavigate(); + const kiosk = useKioskToken(me?.branchIds[0]); + const [clock, setClock] = useState(() => new Date()); + + useEffect(() => { + const id = window.setInterval(() => setClock(new Date()), 1000); + return () => window.clearInterval(id); + }, []); + + const hh = String(clock.getHours()).padStart(2, "0"); + const mm = String(clock.getMinutes()).padStart(2, "0"); + // Device account → sign out (unlock the tablet); manager preview → back to portal. + const isDevice = status === "kiosk"; + const onExit = () => (isDevice ? void signOut() : navigate("/")); + const companyName = kiosk.data?.companyName || me?.companyName || "WorkTrack"; + + return ( +
    + + +
    + W + {companyName} +
    + +
    + {num(`${hh}:${mm}`)} +
    + +
    + {kiosk.data ? ( + + ) : kiosk.isError ? ( +
    {t("common_error")}
    + ) : ( +
    + )} +
    + +

    {t("kiosk_title")}

    +

    {t("kiosk_hint")}

    +

    {t("kiosk_rotates")}

    +
    + ); +} + +/** Renders a QR for [value] as an inline SVG (regenerated whenever it changes). */ +function Qr({ value }: { value: string }) { + const [svg, setSvg] = useState(""); + + useEffect(() => { + let alive = true; + QRCode.toString(value, { type: "svg", margin: 1, errorCorrectionLevel: "M" }) + .then((s) => { + if (alive) setSvg(s); + }) + .catch(() => { + if (alive) setSvg(""); + }); + return () => { + alive = false; + }; + }, [value]); + + return
    ; +} diff --git a/web/src/pages/LeavePage.tsx b/web/src/pages/LeavePage.tsx new file mode 100644 index 0000000..dcb26e7 --- /dev/null +++ b/web/src/pages/LeavePage.tsx @@ -0,0 +1,98 @@ +import { useState } from "react"; +import { useDecideLeave, usePendingApprovals } from "../api/hooks"; +import type { LeaveRequest } from "../api/types"; +import { useI18n } from "../i18n/LocaleProvider"; +import { EmptyState, ErrorState, LoadingState, Toast } from "../ui/components"; + +export function LeavePage() { + const { t, num, shamsi } = useI18n(); + const approvals = usePendingApprovals(); + const decide = useDecideLeave(); + const [toast, setToast] = useState(null); + const [busyId, setBusyId] = useState(null); + + function flash(message: string) { + setToast(message); + window.setTimeout(() => setToast(null), 2500); + } + + async function onDecide(req: LeaveRequest, decision: "APPROVE" | "REJECT") { + let note: string | null = null; + if (decision === "REJECT") { + note = window.prompt(t("leave_reject_prompt")) ?? ""; + if (!note.trim()) return; // rejection requires a note (server enforces too) + } + setBusyId(req.id); + try { + await decide.mutateAsync({ id: req.id, decision, note }); + flash(decision === "APPROVE" ? t("leave_approved") : t("leave_rejected")); + } catch { + flash(t("common_error")); + } finally { + setBusyId(null); + } + } + + return ( + <> +
    +

    {t("leave_title")}

    +
    + + {approvals.isLoading ? ( + + ) : approvals.isError ? ( + void approvals.refetch()} /> + ) : (approvals.data?.length ?? 0) === 0 ? ( + + ) : ( +
    + + + + + + + + + + + {approvals.data!.map((req) => ( + + + + + + + + ))} + +
    {t("leave_employee")}{t("leave_dates")}{t("leave_days")}{t("leave_reason")} +
    {req.employeeName ?? req.employeeId} + {shamsi(req.startDate)} – {shamsi(req.endDate, { withYear: true })} + + {num(req.days)} {t("common_days")} + {req.reason} +
    + + +
    +
    +
    + )} + {toast && } + + ); +} diff --git a/web/src/pages/PaymentSheet.test.tsx b/web/src/pages/PaymentSheet.test.tsx new file mode 100644 index 0000000..4e658b6 --- /dev/null +++ b/web/src/pages/PaymentSheet.test.tsx @@ -0,0 +1,162 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { RunPayslipRow } from "../api/types"; +import { LocaleProvider } from "../i18n/LocaleProvider"; +import { DICTIONARIES } from "../i18n/strings"; + +/** + * The sheet that gets printed and signed, and the file the accountant opens. + * + * These two exist because everything WorkTrack computes stops being usable at + * the edge of the screen. Most workers here have no bank account: pay is + * counted out in cash against a signature, and the accountant keeps the year + * in Excel. What can go wrong is quiet — a signature column that is not empty, + * a CSV whose numbers arrive as text, names that arrive as mojibake. + */ + +vi.mock("../auth/AuthProvider", () => ({ + useAuth: () => ({ me: { companyName: "شرکت ساختمانی کابل" } }), +})); + +const { PaymentSheet, payrollCsv } = await import("./PaymentSheet"); + +function row(over: Partial = {}): RunPayslipRow { + return { + id: "e1_1405_06", + employeeId: "e1", + employeeCode: "E-001", + employeeName: "احمد رحیمی", + currency: "AFN", + gross: 50000, + totalDeductions: 8400, + net: 41600, + incomeTax: 3400, + employerCost: 2250, + costToCompany: 52250, + workedDays: 16, + lopDays: 0, + status: "FINALIZED", + ...over, + }; +} + +function show(rows: RunPayslipRow[]): void { + render( + + + , + ); +} + +describe("the printed sheet", () => { + it("leaves the signature column completely empty", () => { + // The one column the page exists for. Anything printed in it — a dash, a + // zero, a repeated name — is something a person has to sign around. + show([row()]); + const cells = document.querySelectorAll("td.sheet-sign"); + expect(cells).toHaveLength(1); + expect(cells[0].textContent).toBe(""); + }); + + it("keys each line by employee code, not by name alone", () => { + // Two people called احمد in one company is the ordinary case, and a signed + // line has to say which one signed it. + show([ + row({ employeeCode: "E-001" }), + row({ id: "x", employeeCode: "E-007", employeeName: "احمد کریمی" }), + ]); + + // The digits are localised, so read the cells rather than guessing the + // rendered form of "E-001". + const codes = [...document.querySelectorAll("tbody tr")].map( + (tr) => tr.querySelectorAll("td")[1].textContent, + ); + expect(codes).toHaveLength(2); + expect(new Set(codes).size).toBe(2); + expect(codes.every((c) => c?.startsWith("E-"))).toBe(true); + }); + + it("prints a dash rather than a blank for somebody with no code", () => { + // Employees created before codes existed. A blank cell in the key column + // reads as a printing fault. + show([row({ employeeCode: "" })]); + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + it("shows the period the money is for", () => { + show([row()]); + // سنبله is month 6; ۱۴۰۵ the year. A sheet with no period on it cannot be + // filed. + expect(document.body.textContent).toContain("سنبله"); + expect(document.body.textContent).toContain("۱۴۰۵"); + }); + + it("names the company, because the sheet leaves the building", () => { + show([row()]); + expect(screen.getByText("شرکت ساختمانی کابل")).toBeInTheDocument(); + }); + + it("totals what the payer has to count out", () => { + show([row({ net: 41600 }), row({ id: "b", net: 31866 })]); + // 73,466 — the number the person carrying the cash checks before starting. + expect(document.body.textContent).toContain("۷۳,۴۶۶"); + }); + + it("renders into , or the print rule hides it along with the app", () => { + // `body > *:not(.sheet-backdrop)` is what puts the sheet alone on the + // paper. Inside #root, that rule hides #root — and the sheet with it — and + // printing gives a blank page. This is the only assertion that would have + // caught it; the others all passed while it was broken. + show([row()]); + const sheet = document.querySelector(".sheet-backdrop"); + expect(sheet?.parentElement).toBe(document.body); + }); + + it("keeps its own controls off the paper", () => { + show([row()]); + const toolbar = document.querySelector(".sheet-toolbar"); + // The print and close buttons are screen furniture; `no-print` is what the + // stylesheet keys on to drop them. + expect(toolbar?.classList.contains("no-print")).toBe(true); + }); + + it("gives the payer and the approver somewhere to sign", () => { + // An unsigned sheet proves nothing about who handed the cash over. + show([row()]); + expect(screen.getByText(DICTIONARIES.fa.sheet_paid_by)).toBeInTheDocument(); + expect(screen.getByText(DICTIONARIES.fa.sheet_approved_by)).toBeInTheDocument(); + }); +}); + +describe("the spreadsheet", () => { + it("writes numbers Excel can add up", () => { + // Eastern digits arrive as text and every column stops summing — which is + // the one thing the accountant opened the file for. + const csv = payrollCsv([row()]); + expect(csv).toContain("50000"); + expect(csv).not.toMatch(/[۰-۹]/); + }); + + it("starts with a BOM so Windows Excel reads the Dari names", () => { + // Without it the whole name column is mojibake. + expect(payrollCsv([row()]).charCodeAt(0)).toBe(0xfeff); + }); + + it("keeps a name containing a comma in one column", () => { + const csv = payrollCsv([row({ employeeName: 'رحیمی, احمد "ح"' })]); + expect(csv).toContain('"رحیمی, احمد ""ح"""'); + // Header plus one row, and the row did not split into two lines. + expect(csv.trim().split("\n")).toHaveLength(2); + }); + + it("carries the columns an accountant reconciles against", () => { + const header = payrollCsv([]).split("\n")[0]; + for (const column of ["employee_code", "gross", "income_tax", "net", "lop_days"]) { + expect(header).toContain(column); + } + }); + + it("produces a header even with nothing to export", () => { + expect(payrollCsv([]).trim().split("\n")).toHaveLength(1); + }); +}); diff --git a/web/src/pages/PaymentSheet.tsx b/web/src/pages/PaymentSheet.tsx new file mode 100644 index 0000000..46f46bf --- /dev/null +++ b/web/src/pages/PaymentSheet.tsx @@ -0,0 +1,179 @@ +import { createPortal } from "react-dom"; +import type { RunPayslipRow } from "../api/types"; +import { useAuth } from "../auth/AuthProvider"; +import { useI18n } from "../i18n/LocaleProvider"; + +/** + * The sheet a company prints, carries to the workers, and gets signed. + * + * Most workers here have no bank account. Pay is counted out in cash and a + * signature or a thumbprint is taken against it, and without that sheet a + * company cannot answer a tax inspector or a main contractor asking how it + * knows the money arrived. Everything WorkTrack computes stops being usable at + * the edge of the screen until this exists. + * + * So the design is driven by the paper, not by the app: + * + * - The signature column is wide and EMPTY. It is the only column that + * matters at the moment of use, and a narrow one gets signed across. + * - Rows are numbered and keyed by employee code. Two people called احمد in + * one company is the ordinary case, and a signed line has to say which. + * - Totals are at the foot, because the person paying counts the money + * against them before they start. + * - No colour and no zebra striping: these are printed on whatever is in the + * office, and grey backgrounds swallow ink and hide pencil signatures. + */ +export function PaymentSheet({ + runId, + rows, + onClose, +}: { + runId: string; + rows: RunPayslipRow[]; + onClose: () => void; +}) { + const { t, num, shamsiMonthName } = useI18n(); + const { me } = useAuth(); + + // "1405_06" — the run id carries the Solar Hijri period the sheet is for. + const [year, month] = runId.split("_"); + const period = `${shamsiMonthName(Number(month))} ${num(year)}`; + + const totalNet = rows.reduce((sum, r) => sum + r.net, 0); + const totalGross = rows.reduce((sum, r) => sum + r.gross, 0); + const currency = rows[0]?.currency ?? "AFN"; + const money = (n: number): string => num(n.toLocaleString("en-US")); + + // Rendered into , not into the React tree where it is written. + // + // The print stylesheet hides `body > *:not(.sheet-backdrop)` — that is what + // puts the sheet alone on the paper. Left inside #root, that rule hides #root + // and takes the sheet down with it, and printing produces a blank page. Every + // test passed anyway: jsdom does not print, and the modal looked right on + // screen. Only opening the browser showed it. + return createPortal( +
    +
    e.stopPropagation()}> + {/* Screen-only controls. `no-print` removes them from the paper. */} +
    + + +
    + +
    +

    {t("sheet_title")}

    +
    + {me?.companyName} + {t("sheet_period", period)} +
    +
    + + + + + + + + + + + {/* The reason the page exists. */} + + + + + {rows.map((r, i) => ( + + + + + + + + + ))} + + + + + + + + + +
    {t("sheet_row")}{t("sheet_code")}{t("sheet_name")}{t("sheet_gross")}{t("sheet_deductions")}{t("sheet_net")}{t("sheet_signature")}
    {num(i + 1)}{num(r.employeeCode || "—")}{r.employeeName}{money(r.gross)}{money(r.totalDeductions)}{money(r.net)} +
    {t("sheet_total", num(rows.length))}{money(totalGross)}{money(totalGross - totalNet)} + {money(totalNet)} {currency} + +
    + + {/* Who counted the money out, and who checked. An unsigned sheet proves + nothing about the person who handed the cash over. */} +
    +
    + {t("sheet_paid_by")} +
    +
    +
    + {t("sheet_approved_by")} +
    +
    +
    +
    +
    , + document.body, + ); +} + +/** + * The same run as a spreadsheet. + * + * Every accountant here works in Excel and will go on doing so. A figure that + * only exists on a screen gets retyped, and a retyped payroll is a payroll + * with a typo in it. + */ +export function payrollCsv(rows: RunPayslipRow[]): string { + const header = [ + "employee_code", + "employee_name", + "gross", + "income_tax", + "total_deductions", + "net", + "currency", + "worked_days", + "lop_days", + ]; + + // Latin digits and a plain comma on purpose: this file is read by Excel, not + // by a person. Eastern digits arrive as text and every column stops adding + // up, which is the one thing the accountant opened it for. + const escape = (v: string | number): string => { + const s = String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + + const lines = rows.map((r) => + [ + r.employeeCode, + r.employeeName, + r.gross, + r.incomeTax, + r.totalDeductions, + r.net, + r.currency, + r.workedDays, + r.lopDays, + ] + .map(escape) + .join(","), + ); + + // A BOM, so Excel on Windows reads the Dari names as UTF-8 rather than as + // mojibake. Without it the whole name column is unreadable. + return `${header.join(",")}\n${lines.join("\n")}\n`; +} diff --git a/web/src/pages/PayrollPage.test.tsx b/web/src/pages/PayrollPage.test.tsx new file mode 100644 index 0000000..b36f3d3 --- /dev/null +++ b/web/src/pages/PayrollPage.test.tsx @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import type { PayrollRun, PayrollRunResult } from "../api/types"; +import { LocaleProvider } from "../i18n/LocaleProvider"; + +/** + * What a payroll run is allowed to hide. + * + * Two things about a run are true but invisible in the numbers themselves: that + * the month has not finished, and that some employees produced no payslip at + * all. Both make a run look complete when it is not, and both cost real money. + */ + +const runResult = vi.hoisted(() => ({ + current: { + runId: "1405_05", + periodYear: 1405, + periodMonth: 5, + currency: "AFN", + payslipCount: 1, + totalNet: 28100, + totalGross: 30000, + totalTax: 1900, + totalEmployerCost: 0, + periodComplete: true, + skippedNoSalary: [], + } as PayrollRunResult, +})); +const runs = vi.hoisted(() => ({ current: [] as PayrollRun[] })); + +vi.mock("../api/hooks", () => ({ + usePayrollRuns: () => ({ + data: runs.current, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useRunPayroll: () => ({ + mutateAsync: vi.fn(async () => runResult.current), + isPending: false, + }), + useRunPayslips: () => ({ data: [], isLoading: false, isError: false }), + // The page also carries the earnings-and-deductions card and the advances + // card; each is exercised by its own test, so keep them inert here. + useSalaryComponents: () => ({ data: [], isLoading: false, isError: false, refetch: vi.fn() }), + useAdvances: () => ({ data: [], isLoading: false, isError: false, refetch: vi.fn() }), + useEmployees: () => ({ data: { data: [] }, isLoading: false, isError: false }), + useCreateAdvance: () => ({ mutateAsync: vi.fn(), isPending: false }), + useCancelAdvance: () => ({ mutateAsync: vi.fn(), isPending: false }), + usePieceRecords: () => ({ data: [], isLoading: false, isError: false, refetch: vi.fn() }), + useRecordPieces: () => ({ mutateAsync: vi.fn(), isPending: false }), + useDeletePieceRecord: () => ({ mutateAsync: vi.fn(), isPending: false }), + useSaveSalaryComponent: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +vi.mock("../auth/AuthProvider", () => ({ + useHasPermission: () => () => true, + useAuth: () => ({ me: { timezone: "Asia/Kabul" } }), +})); + +const { PayrollPage } = await import("./PayrollPage"); + +function run(over: Partial = {}): PayrollRun { + return { + id: "1405_05", + periodYear: 1405, + periodMonth: 5, + status: "APPROVED", + currency: "AFN", + payslipCount: 12, + totalGross: 360000, + totalNet: 337200, + totalTax: 22800, + totalEmployerCost: 0, + periodComplete: true, + lockedAt: null, + createdAt: null, + ...over, + }; +} + +function renderPage(): void { + render({() as ReactNode}); +} + +describe("payroll runs", () => { + beforeEach(() => { + // The provider reads the stored locale; assertions below are the English. + localStorage.setItem("worktrack.locale", "en"); + runs.current = []; + runResult.current = { ...runResult.current, skippedNoSalary: [] }; + }); + + it("marks a run made before its month ended as provisional", () => { + runs.current = [run({ periodComplete: false })]; + renderPage(); + expect(screen.getByText("Provisional")).toBeInTheDocument(); + }); + + it("does not mark a completed month", () => { + runs.current = [run({ periodComplete: true })]; + renderPage(); + expect(screen.queryByText("Provisional")).not.toBeInTheDocument(); + }); + + it("treats a run from before the field existed as a completed month", () => { + // Older runs carry no periodComplete; they were all whole months. + const legacy = run(); + delete (legacy as Partial).periodComplete; + runs.current = [legacy]; + renderPage(); + expect(screen.queryByText("Provisional")).not.toBeInTheDocument(); + }); + + it("names the employees a run could not pay", async () => { + runResult.current = { + ...runResult.current, + skippedNoSalary: [ + { employeeId: "e2", name: "Zahra Ahmadi" }, + { employeeId: "e3", name: "Omid Noori" }, + ], + }; + renderPage(); + + await userEvent.click(screen.getByText("Run payroll")); + + await waitFor(() => + expect( + screen.getByText("2 employees were left out of this run"), + ).toBeInTheDocument(), + ); + expect(screen.getByText("Zahra Ahmadi")).toBeInTheDocument(); + expect(screen.getByText("Omid Noori")).toBeInTheDocument(); + }); + + it("says nothing when everyone was paid", async () => { + renderPage(); + await userEvent.click(screen.getByText("Run payroll")); + + await waitFor(() => + expect(screen.getByText(/Payroll calculated/)).toBeInTheDocument(), + ); + expect(screen.queryByText(/left out of this run/)).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/PayrollPage.tsx b/web/src/pages/PayrollPage.tsx new file mode 100644 index 0000000..e1ab586 --- /dev/null +++ b/web/src/pages/PayrollPage.tsx @@ -0,0 +1,256 @@ +import { useState } from "react"; +import { usePayrollRuns, useRunPayroll, useRunPayslips } from "../api/hooks"; +import type { PayrollRun } from "../api/types"; +import { useHasPermission } from "../auth/AuthProvider"; +import { useI18n } from "../i18n/LocaleProvider"; +import { EmptyState, ErrorState, LoadingState, StatusChip, Toast } from "../ui/components"; +import { shamsiToday } from "../shamsi/solarHijri"; +import { AdvancesCard } from "./AdvancesCard"; +import { PaymentSheet, payrollCsv } from "./PaymentSheet"; +import { PieceWorkCard } from "./PieceWorkCard"; +import { SalaryComponentsCard } from "./SalaryComponentsCard"; + +const SHAMSI_MONTHS_FA = [ + "حمل", "ثور", "جوزا", "سرطان", "اسد", "سنبله", + "میزان", "عقرب", "قوس", "جدی", "دلو", "حوت", +]; + +export function PayrollPage() { + const { t, num, locale, shamsiMonthName } = useI18n(); + const can = useHasPermission(); + const runs = usePayrollRuns(); + const runPayroll = useRunPayroll(); + + const today = shamsiToday(); + const [year, setYear] = useState(today.year); + const [month, setMonth] = useState(today.month); + const [openRun, setOpenRun] = useState(null); + const [toast, setToast] = useState(null); + + const [skipped, setSkipped] = useState>([]); + const [exited, setExited] = useState>([]); + + async function onRun() { + const result = await runPayroll.mutateAsync({ periodYear: year, periodMonth: month }); + // People with no salary configured earn nothing and produce no payslip. + // A run that quietly leaves them out looks complete and is not. + setSkipped(result.skippedNoSalary ?? []); + setExited(result.skippedExited ?? []); + setToast(t("pay_run_done", num(result.payslipCount))); + window.setTimeout(() => setToast(null), 2800); + } + + if (openRun) { + return setOpenRun(null)} />; + } + + return ( + <> +
    +

    {t("pay_title")}

    + {can("payroll:run") && ( +
    + + + +
    + )} +
    + + {exited.length > 0 && ( +
    + {t("pay_exited_title", num(exited.length))} +

    {t("pay_exited_body")}

    +
      + {exited.map((e) => ( +
    • {e.name}
    • + ))} +
    +
    + )} + + {skipped.length > 0 && ( +
    + {t("pay_skipped_title", num(skipped.length))} +

    {t("pay_skipped_body")}

    +
      + {skipped.map((e) => ( +
    • {e.name}
    • + ))} +
    +
    + )} + + {can("payroll:run") && year === today.year && month >= today.month && ( +

    {t("pay_provisional_hint")}

    + )} + + {runs.isLoading ? ( + + ) : runs.isError ? ( + void runs.refetch()} /> + ) : (runs.data?.length ?? 0) === 0 ? ( + + ) : ( +
    + + + + + + + + + + + + + {runs.data!.map((run: PayrollRun) => ( + + + + + + + + + + ))} + +
    {t("pay_period")}{t("pay_status")}{t("pay_employees")}{t("pay_total_gross")}{t("pay_total_tax")}{t("pay_total_net")} +
    + {shamsiMonthName(run.periodMonth)} {locale === "en" ? run.periodYear : num(run.periodYear)} + {run.periodComplete === false && ( + + {t("pay_provisional")} + + )} + + + {num(run.payslipCount)}{num(money(run.totalGross))} {run.currency}{num(money(run.totalTax))} {run.currency}{num(money(run.totalNet))} {run.currency} + +
    +
    + )} + + + + + {toast && } + + ); +} + +function RunDetail({ runId, onBack }: { runId: string; onBack: () => void }) { + const { t, num } = useI18n(); + const payslips = useRunPayslips(runId); + const [sheet, setSheet] = useState(false); + + function downloadCsv(): void { + const blob = new Blob([payrollCsv(payslips.data ?? [])], { + type: "text/csv;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `payroll-${runId}.csv`; + a.click(); + // Revoked immediately: the click has already handed the blob to the + // browser, and leaving it alive holds the whole file in memory. + URL.revokeObjectURL(url); + } + + const ready = !payslips.isLoading && !payslips.isError && (payslips.data?.length ?? 0) > 0; + + return ( + <> +
    +

    {t("pay_title")}

    +
    + {ready && ( + <> + + + + )} + +
    +
    + + {sheet && ( + setSheet(false)} /> + )} + + {payslips.isLoading ? ( + + ) : payslips.isError ? ( + void payslips.refetch()} /> + ) : ( +
    + + + + + + + + + + + + + + {payslips.data!.map((p) => ( + + + + + + + + + + ))} + +
    {t("pay_employee")}{t("pay_gross")}{t("pay_tax")}{t("pay_deductions")}{t("pay_net")}{t("pay_ctc")}{t("pay_worked_days")}
    {p.employeeName}{num(money(p.gross))} {p.currency}{num(money(p.incomeTax))} {p.currency}{num(money(p.totalDeductions))} {p.currency}{num(money(p.net))} {p.currency}{num(money(p.costToCompany))} {p.currency}{num(p.workedDays)}
    +
    + )} + + ); +} + +function money(n: number): string { + return n.toLocaleString("en-US"); +} diff --git a/web/src/pages/PieceWorkCard.tsx b/web/src/pages/PieceWorkCard.tsx new file mode 100644 index 0000000..7911adb --- /dev/null +++ b/web/src/pages/PieceWorkCard.tsx @@ -0,0 +1,245 @@ +import { type FormEvent, useState } from "react"; +import { + useDeletePieceRecord, + useEmployees, + usePieceRecords, + useRecordPieces, +} from "../api/hooks"; +import { ApiError } from "../api/client"; +import type { PieceRecord } from "../api/types"; +import { useHasPermission } from "../auth/AuthProvider"; +import { useI18n } from "../i18n/LocaleProvider"; +import { EmptyState, ErrorState, LoadingState, Toast } from "../ui/components"; + +/** + * The workshop's piece book. + * + * For anybody paid per piece, these counts ARE their wage — payroll multiplies + * the total in the period by their rate and that is the whole of their basic + * pay. So this is not a reporting screen; it is the same kind of act as + * setting a salary, and it is gated the same way. + * + * Entries are kept one per day rather than as a running monthly total, because + * a total nobody can break down is a total nobody can dispute — and disputes + * about piece counts are exactly what a workshop's book exists to settle. + */ +export function PieceWorkCard() { + const { t, num, shamsi } = useI18n(); + const can = useHasPermission(); + const records = usePieceRecords(); + const employees = useEmployees({}); + const remove = useDeletePieceRecord(); + + const [showForm, setShowForm] = useState(false); + const [toast, setToast] = useState(null); + const canWrite = can("payroll:run"); + + function flash(message: string): void { + setToast(message); + window.setTimeout(() => setToast(null), 2800); + } + + async function onDelete(record: PieceRecord): Promise { + if (!window.confirm(t("piece_delete_confirm", record.employeeName))) return; + try { + await remove.mutateAsync(record.id); + flash(t("piece_deleted")); + } catch (err) { + flash(err instanceof ApiError ? err.message : t("common_error")); + } + } + + const rows = records.data ?? []; + + return ( +
    +
    +
    +

    {t("piece_title")}

    +

    {t("piece_sub")}

    +
    + {canWrite && ( + + )} +
    + + {records.isLoading ? ( + + ) : records.isError ? ( + void records.refetch()} /> + ) : rows.length === 0 ? ( + + ) : ( +
    + + + + + + + + {canWrite && + + + {rows.map((r) => ( + + + + + + {canWrite && ( + + )} + + ))} + +
    {t("piece_employee")}{t("piece_date")}{t("piece_quantity")}{t("piece_note")}} +
    {r.employeeName}{shamsi(r.date, { withYear: true })}{num(r.quantity)}{r.note ?? ""} + +
    +
    + )} + + {showForm && ( + ({ + id: e.id, + name: `${e.firstName} ${e.lastName}`.trim(), + }))} + onClose={() => setShowForm(false)} + onSaved={() => { + setShowForm(false); + flash(t("piece_saved")); + }} + /> + )} + + {toast && } +
    + ); +} + +function PieceForm({ + employees, + onClose, + onSaved, +}: { + employees: { id: string; name: string }[]; + onClose: () => void; + onSaved: () => void; +}) { + const { t } = useI18n(); + const record = useRecordPieces(); + const [error, setError] = useState(null); + const [form, setForm] = useState({ + employeeId: "", + date: isoToday(), + quantity: "", + note: "", + }); + + function set(key: K, value: string): void { + setForm((f) => ({ ...f, [key]: value })); + } + + async function onSubmit(e: FormEvent): Promise { + e.preventDefault(); + setError(null); + + const quantity = Number(form.quantity); + if (!form.employeeId || !Number.isFinite(quantity) || quantity <= 0) { + setError(t("piece_err_required")); + return; + } + + try { + await record.mutateAsync({ + employeeId: form.employeeId, + date: form.date, + quantity, + note: form.note.trim() || null, + }); + onSaved(); + } catch (err) { + setError(err instanceof ApiError ? err.message : t("common_error")); + } + } + + return ( +
    +
    e.stopPropagation()} onSubmit={(e) => void onSubmit(e)}> +

    {t("piece_add")}

    + +
    + + +
    + +
    +
    + + set("quantity", e.target.value)} + /> +
    +
    + + set("date", e.target.value)} + /> +
    +
    + +
    + + set("note", e.target.value)} /> +
    + + {error &&

    {error}

    } + +
    + + +
    +
    +
    + ); +} + +function isoToday(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( + d.getDate(), + ).padStart(2, "0")}`; +} diff --git a/web/src/pages/SalaryComponentsCard.test.tsx b/web/src/pages/SalaryComponentsCard.test.tsx new file mode 100644 index 0000000..e9472b5 --- /dev/null +++ b/web/src/pages/SalaryComponentsCard.test.tsx @@ -0,0 +1,228 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import type { SalaryComponent } from "../api/types"; +import { LocaleProvider } from "../i18n/LocaleProvider"; + +/** + * Allowances and deductions — the only route by which anything other than basic + * pay, income tax and the absence deduction reaches a payslip. + */ + +const saved = vi.hoisted(() => ({ calls: [] as Array> })); +const components = vi.hoisted(() => ({ current: [] as SalaryComponent[] })); +const permissions = vi.hoisted(() => ({ current: new Set(["payroll:read", "payroll:run"]) })); +const failure = vi.hoisted(() => ({ current: null as Error | null })); + +vi.mock("../api/hooks", () => ({ + useSalaryComponents: () => ({ + data: components.current, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useSaveSalaryComponent: () => ({ + mutateAsync: vi.fn(async (args: Record) => { + if (failure.current) throw failure.current; + saved.calls.push(args); + return {}; + }), + isPending: false, + }), +})); + +vi.mock("../auth/AuthProvider", () => ({ + useHasPermission: () => (p: string) => permissions.current.has(p), +})); + +const { SalaryComponentsCard } = await import("./SalaryComponentsCard"); + +function component(over: Partial = {}): SalaryComponent { + return { + id: "c1", + name: "Transport allowance", + code: "TRANSPORT", + type: "EARNING", + calc: "FIXED", + value: 2000, + taxable: true, + scope: "ALL", + active: true, + ...over, + }; +} + +function renderCard(): void { + render({() as ReactNode}); +} + +describe("earnings and deductions", () => { + beforeEach(() => { + localStorage.setItem("worktrack.locale", "en"); + saved.calls = []; + components.current = []; + failure.current = null; + permissions.current = new Set(["payroll:read", "payroll:run"]); + }); + + it("says plainly what a payslip contains when nothing is defined", () => { + renderCard(); + expect( + screen.getByText(/Payslips show basic salary, income tax and the absence deduction only/i), + ).toBeInTheDocument(); + }); + + it("groups components by type", () => { + components.current = [ + component(), + component({ id: "c2", name: "Loan repayment", code: "LOAN", type: "DEDUCTION" }), + component({ id: "c3", name: "Pension", code: "PENSION", type: "EMPLOYER_COST" }), + ]; + renderCard(); + + expect(screen.getByRole("heading", { name: "Earning" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Deduction" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Employer cost" })).toBeInTheDocument(); + }); + + it("does not offer percent of gross for an earning", async () => { + // Payroll treats a percent-of-gross earning as a fixed amount, because gross + // is the sum of the earnings. Offering it would produce 10 afghani where the + // administrator meant 10 percent. + renderCard(); + await userEvent.click(screen.getByText("Add item")); + + const calc = screen.getByLabelText("How it is calculated"); + expect(within(calc).queryByText("Percent of gross")).not.toBeInTheDocument(); + expect(within(calc).getByText("Percent of basic salary")).toBeInTheDocument(); + }); + + it("offers percent of gross for a deduction", async () => { + renderCard(); + await userEvent.click(screen.getByText("Add item")); + await userEvent.selectOptions(screen.getByLabelText("Type"), "DEDUCTION"); + + expect( + within(screen.getByLabelText("How it is calculated")).getByText("Percent of gross"), + ).toBeInTheDocument(); + }); + + it("drops percent of gross when the type changes back to an earning", async () => { + renderCard(); + await userEvent.click(screen.getByText("Add item")); + + const type = screen.getByLabelText("Type"); + await userEvent.selectOptions(type, "DEDUCTION"); + await userEvent.selectOptions(screen.getByLabelText("How it is calculated"), "PERCENT_OF_GROSS"); + await userEvent.selectOptions(type, "EARNING"); + + // Left as-is it would have submitted a combination payroll misreads. + expect((screen.getByLabelText("How it is calculated") as HTMLSelectElement).value).toBe("FIXED"); + }); + + it("saves a new allowance", async () => { + renderCard(); + await userEvent.click(screen.getByText("Add item")); + await userEvent.type(screen.getByLabelText("Name"), "Transport allowance"); + await userEvent.type(screen.getByLabelText("Code"), "transport"); + await userEvent.clear(screen.getByLabelText("Amount (AFN)")); + await userEvent.type(screen.getByLabelText("Amount (AFN)"), "2000"); + await userEvent.click(screen.getByText("Save")); + + expect(saved.calls).toHaveLength(1); + expect(saved.calls[0]).toEqual({ + id: undefined, + body: { + name: "Transport allowance", + code: "TRANSPORT", // upper-cased for the server's pattern + type: "EARNING", + calc: "FIXED", + value: 2000, + taxable: true, + scope: "ALL", + active: true, + }, + }); + }); + + it("refuses a code the server's pattern would reject", async () => { + renderCard(); + await userEvent.click(screen.getByText("Add item")); + await userEvent.type(screen.getByLabelText("Name"), "Transport"); + await userEvent.type(screen.getByLabelText("Code"), "trans port!"); + await userEvent.click(screen.getByText("Save")); + + expect(screen.getByText(/must be capitals, digits and _ only/i)).toBeInTheDocument(); + expect(saved.calls).toHaveLength(0); + }); + + it("refuses a percentage over 100", async () => { + renderCard(); + await userEvent.click(screen.getByText("Add item")); + await userEvent.type(screen.getByLabelText("Name"), "Bonus"); + await userEvent.type(screen.getByLabelText("Code"), "BONUS"); + await userEvent.selectOptions(screen.getByLabelText("How it is calculated"), "PERCENT_OF_BASIC"); + await userEvent.clear(screen.getByLabelText("Percent")); + await userEvent.type(screen.getByLabelText("Percent"), "150"); + await userEvent.click(screen.getByText("Save")); + + expect(screen.getByText(/cannot be more than 100/i)).toBeInTheDocument(); + expect(saved.calls).toHaveLength(0); + }); + + it("names the clashing code when the server rejects a duplicate", async () => { + failure.current = new Error("A component with code TRANSPORT exists"); + renderCard(); + await userEvent.click(screen.getByText("Add item")); + await userEvent.type(screen.getByLabelText("Name"), "Transport"); + await userEvent.type(screen.getByLabelText("Code"), "TRANSPORT"); + await userEvent.click(screen.getByText("Save")); + + expect(screen.getByText("Code TRANSPORT is already in use.")).toBeInTheDocument(); + }); + + it("loads a component into the form for editing and sends its id", async () => { + components.current = [component()]; + renderCard(); + await userEvent.click(screen.getByText("Edit")); + + expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("Transport allowance"); + await userEvent.click(screen.getByText("Save")); + + expect(saved.calls[0].id).toBe("c1"); + }); + + it("deactivates rather than deletes, so past payslips keep their line", async () => { + components.current = [component()]; + renderCard(); + await userEvent.click(screen.getByText("Deactivate")); + + expect(saved.calls).toHaveLength(1); + expect(saved.calls[0].id).toBe("c1"); + expect((saved.calls[0].body as SalaryComponent).active).toBe(false); + }); + + it("marks a tax-exempt earning", () => { + components.current = [component({ taxable: false })]; + renderCard(); + expect(screen.getByText("Exempt")).toBeInTheDocument(); + }); + + it("warns that a change does not touch payslips already produced", () => { + components.current = [component()]; + renderCard(); + expect(screen.getByText(/does not alter payslips already produced/i)).toBeInTheDocument(); + }); + + it("shows the list but no controls to someone who may only read payroll", () => { + permissions.current = new Set(["payroll:read"]); + components.current = [component()]; + renderCard(); + + expect(screen.getByText("Transport allowance")).toBeInTheDocument(); + expect(screen.queryByText("Add item")).not.toBeInTheDocument(); + expect(screen.queryByText("Edit")).not.toBeInTheDocument(); + expect(screen.queryByText("Deactivate")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/SalaryComponentsCard.tsx b/web/src/pages/SalaryComponentsCard.tsx new file mode 100644 index 0000000..675f733 --- /dev/null +++ b/web/src/pages/SalaryComponentsCard.tsx @@ -0,0 +1,383 @@ +import { useState } from "react"; +import { useSalaryComponents, useSaveSalaryComponent } from "../api/hooks"; +import type { SalaryComponent, SalaryComponentWrite } from "../api/types"; +import { useHasPermission } from "../auth/AuthProvider"; +import { useI18n } from "../i18n/LocaleProvider"; +import { Chip, EmptyState, ErrorState, LoadingState, Switch, Toast } from "../ui/components"; + +/** + * Allowances, deductions and employer costs. + * + * These are the only way anything other than basic pay, income tax and the + * absence deduction reaches a payslip. Until this screen existed the collection + * had an API and no way in, so a company could not give anyone a transport + * allowance. + * + * It lives on the payroll page rather than in settings on purpose: writing a + * component needs `payroll:run`, which the payroll administrator holds and the + * settings page — gated on `settings:write` — would have hidden from them. + */ + +const TYPES = ["EARNING", "DEDUCTION", "EMPLOYER_COST"] as const; + +/** + * Percent-of-gross is not offered for an earning, and that is not an oversight + * in this form: gross is the sum of the earnings, so an earning derived from it + * is circular. Payroll resolves that by treating such a component as a fixed + * amount — 10 would pay 10 afghani, not 10 percent — so offering the choice + * here would quietly produce a wrong payslip. + */ +function calcsFor(type: SalaryComponent["type"]): SalaryComponent["calc"][] { + return type === "EARNING" + ? ["FIXED", "PERCENT_OF_BASIC"] + : ["FIXED", "PERCENT_OF_BASIC", "PERCENT_OF_GROSS"]; +} + +const BLANK: SalaryComponentWrite = { + name: "", + code: "", + type: "EARNING", + calc: "FIXED", + value: 0, + taxable: true, + scope: "ALL", + active: true, +}; + +export function SalaryComponentsCard() { + const { t, num } = useI18n(); + const can = useHasPermission(); + const canManage = can("payroll:run"); + + const components = useSalaryComponents(); + const save = useSaveSalaryComponent(); + + const [editingId, setEditingId] = useState(null); + const [open, setOpen] = useState(false); + const [form, setForm] = useState(BLANK); + const [error, setError] = useState(null); + const [toast, setToast] = useState(null); + + const flash = (m: string) => { + setToast(m); + window.setTimeout(() => setToast(null), 2500); + }; + + function reset() { + setForm(BLANK); + setEditingId(null); + setError(null); + setOpen(false); + } + + function startNew() { + setForm(BLANK); + setEditingId(null); + setError(null); + setOpen(true); + } + + function startEdit(c: SalaryComponent) { + const { id, ...rest } = c; + void id; + setForm(rest); + setEditingId(c.id); + setError(null); + setOpen(true); + } + + function setType(type: SalaryComponent["type"]) { + // Switching away from an earning keeps the calc; switching to one may leave + // percent-of-gross selected, which this form does not offer. + const calcs = calcsFor(type); + setForm((f) => ({ + ...f, + type, + calc: calcs.includes(f.calc) ? f.calc : "FIXED", + })); + } + + async function submit() { + setError(null); + const name = form.name.trim(); + const code = form.code.trim().toUpperCase(); + + if (!name) return setError(t("comp_err_name")); + if (!/^[A-Z0-9_]{1,24}$/.test(code)) return setError(t("comp_err_code")); + if (!Number.isFinite(form.value) || form.value < 0) return setError(t("comp_err_value")); + if (form.calc !== "FIXED" && form.value > 100) return setError(t("comp_err_percent")); + + try { + await save.mutateAsync({ + id: editingId ?? undefined, + body: { ...form, name, code }, + }); + flash(t("comp_saved")); + reset(); + } catch (err) { + // The server refuses a duplicate code; say which one rather than "error". + const message = err instanceof Error ? err.message : ""; + setError(/exists/i.test(message) ? t("comp_err_duplicate", code) : t("common_error")); + } + } + + /** Deactivating keeps the history: payroll ignores it, past payslips keep it. */ + async function toggleActive(c: SalaryComponent) { + const { id, ...rest } = c; + try { + await save.mutateAsync({ id, body: { ...rest, active: !c.active } }); + flash(c.active ? t("comp_deactivated") : t("comp_activated")); + } catch { + flash(t("common_error")); + } + } + + const rows = components.data ?? []; + const byType = (type: SalaryComponent["type"]) => rows.filter((c) => c.type === type); + + function amountOf(c: SalaryComponent): string { + if (c.calc === "FIXED") return `${num(c.value)} ${t("comp_afn")}`; + return `${num(c.value)}٪ ${ + c.calc === "PERCENT_OF_BASIC" ? t("comp_of_basic") : t("comp_of_gross") + }`; + } + + return ( +
    +
    +
    +

    {t("comp_title")}

    +

    {t("comp_hint")}

    +
    + {canManage && !open && ( + + )} +
    + + {open && canManage && ( +
    +
    + + +
    + + setForm({ ...form, code: e.target.value.toUpperCase() })} + /> + + {t("comp_code_hint")} + +
    + + + +
    + + + + {t("comp_scope_hint")} + +
    +
    + +
    +
    + + + {form.type === "EARNING" && ( + + {t("comp_calc_earning_hint")} + + )} +
    + + + + {form.type === "EARNING" && ( +
    + {t("comp_taxable")} + + {t("comp_taxable_hint")} +
    + )} +
    + + {error &&

    {error}

    } + +
    + + +
    +
    + )} + + {components.isLoading ? ( + + ) : components.isError ? ( + void components.refetch()} /> + ) : rows.length === 0 ? ( + + ) : ( + TYPES.filter((ty) => byType(ty).length > 0).map((ty) => ( +
    +

    {t(`comp_type_${ty.toLowerCase()}`)}

    +
    + + + + + + + {ty === "EARNING" && } + + {canManage && + + + {byType(ty).map((c) => ( + + + {/* No dir override: the string is "500 افغانی" — number + then unit — and forcing LTR here reordered it on + screen to "افغانی 500". */} + + + {ty === "EARNING" && ( + + )} + + {canManage && ( + + )} + + ))} + +
    {t("comp_name")}{t("comp_amount")}{t("comp_scope")}{t("comp_taxable")}{t("comp_status")}} +
    +
    {c.name}
    +
    + {c.code} +
    +
    {amountOf(c)} + + {c.scope === "INDIVIDUAL" + ? t("comp_scope_individual") + : t("comp_scope_all")} + + + + {c.taxable ? t("common_yes") : t("comp_tax_exempt")} + + + + {c.active ? t("comp_active") : t("comp_inactive")} + + + {" "} + +
    +
    +
    + )) + )} + + {rows.length > 0 &&

    {t("comp_rerun_hint")}

    } + + {toast && } +
    + ); +} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..41e89fe --- /dev/null +++ b/web/src/pages/SettingsPage.tsx @@ -0,0 +1,404 @@ +import { useEffect, useState } from "react"; +import { + useCreateKioskAccount, + useKioskAccounts, + useResetKioskAccount, + useSettings, + useUpdateSettings, +} from "../api/hooks"; +import type { CompanyFeatures, CompanySettings, KioskAccountCreated } from "../api/types"; +import { useAuth, useHasPermission } from "../auth/AuthProvider"; +import { useI18n } from "../i18n/LocaleProvider"; +import { DangerZoneCard } from "./DangerZoneCard"; +import { HolidaysCard } from "./HolidaysCard"; +import { SupportCard } from "./SupportCard"; +import { Chip, ErrorState, LoadingState, Switch, Toast } from "../ui/components"; + +const FEATURE_KEYS: (keyof CompanyFeatures)[] = [ + "shifts", + "leave", + "payroll", + "regularization", + "announcements", + "geofencing", + "qrKiosk", + "faceRecognition", + "finance", +]; + +const FEATURE_LABEL: Record = { + shifts: "feat_shifts", + leave: "feat_leave", + payroll: "feat_payroll", + regularization: "feat_regularization", + announcements: "feat_announcements", + geofencing: "feat_geofencing", + qrKiosk: "feat_qr", + faceRecognition: "feat_face", + finance: "feat_finance", +}; + +// ISO weekday numbers in Afghan week order (Saturday-first). +const WEEK_DAYS: { iso: number; key: string }[] = [ + { iso: 6, key: "wd_sat" }, + { iso: 7, key: "wd_sun" }, + { iso: 1, key: "wd_mon" }, + { iso: 2, key: "wd_tue" }, + { iso: 3, key: "wd_wed" }, + { iso: 4, key: "wd_thu" }, + { iso: 5, key: "wd_fri" }, +]; + +export function SettingsPage() { + const { t, num } = useI18n(); + const { refreshMe } = useAuth(); + const settings = useSettings(); + const save = useUpdateSettings(); + const [draft, setDraft] = useState(null); + const [toast, setToast] = useState(null); + + // Seed the editable draft once settings load. + useEffect(() => { + if (settings.data && !draft) setDraft(structuredClone(settings.data)); + }, [settings.data, draft]); + + if (settings.isLoading || !draft) { + return settings.isError ? ( + void settings.refetch()} /> + ) : ( + + ); + } + + function flash(msg: string) { + setToast(msg); + window.setTimeout(() => setToast(null), 2500); + } + + async function onSave() { + if (!draft) return; + try { + await save.mutateAsync(draft); + // Refresh the session so the sidebar and feature-gated routes reflect the + // updated module flags immediately (otherwise they stay stale until a + // full reload). A refresh failure shouldn't mask a successful save. + await refreshMe().catch(() => undefined); + flash(t("set_saved")); + } catch { + flash(t("common_error")); + } + } + + const feature = (k: keyof CompanyFeatures) => draft.features[k]; + const setFeature = (k: keyof CompanyFeatures, v: boolean) => + setDraft({ ...draft, features: { ...draft.features, [k]: v } }); + + const weekend = new Set(draft.policies.weekendDays); + const toggleWeekend = (iso: number) => { + const next = new Set(weekend); + if (next.has(iso)) next.delete(iso); + else next.add(iso); + setDraft({ ...draft, policies: { ...draft.policies, weekendDays: [...next].sort() } }); + }; + + const dailyHours = Math.round((draft.policies.standardDailyMinutes / 60) * 10) / 10; + + return ( + <> +
    +

    {t("set_title")}

    + +
    + +
    + {/* Features (editable modules) */} +
    +

    {t("set_features")}

    +

    {t("set_features_hint")}

    +
    + {FEATURE_KEYS.map((k) => ( + + ))} +
    +
    + + {/* Work policies */} +
    +

    {t("set_policies")}

    + +
    + + + setDraft({ + ...draft, + policies: { + ...draft.policies, + standardDailyMinutes: Math.round(Number(e.target.value) * 60), + }, + }) + } + /> +
    + +
    + +
    + {WEEK_DAYS.map((d) => ( + + ))} +
    +
    + +
    + + + setDraft({ + ...draft, + policies: { ...draft.policies, lateGraceMinutes: Number(e.target.value) }, + }) + } + /> +
    + +
    +
    + {t("pol_overtime")} +
    + + setDraft({ ...draft, policies: { ...draft.policies, overtimeEnabled: v } }) + } + label={t("pol_overtime")} + /> +
    +
    + + {/* Profile */} +
    +

    {t("set_profile")}

    +
    + + + setDraft({ + ...draft, + profile: { ...draft.profile, currency: e.target.value.toUpperCase() }, + }) + } + /> +
    +
    + + + setDraft({ ...draft, profile: { ...draft.profile, timezone: e.target.value } }) + } + /> +
    +

    + {t("pol_daily_hours")}: {num(dailyHours)} +

    +
    +
    + + {draft.features.qrKiosk && } + + {toast && } + + + + + ); +} + +/** Provision and manage dedicated kiosk device logins (COMPANY_ADMIN/HR_ADMIN). */ +function KioskDevicesCard() { + const { t } = useI18n(); + const can = useHasPermission(); + const canManage = can("employees:write"); + const accounts = useKioskAccounts(true); + const create = useCreateKioskAccount(); + const reset = useResetKioskAccount(); + const [label, setLabel] = useState(""); + const [creds, setCreds] = useState<{ email: string; password: string } | null>(null); + const [toast, setToast] = useState(null); + + const flash = (m: string) => { + setToast(m); + window.setTimeout(() => setToast(null), 2500); + }; + + async function onCreate() { + if (!label.trim() || create.isPending) return; + try { + const acc: KioskAccountCreated = await create.mutateAsync({ label: label.trim() }); + setCreds({ email: acc.email, password: acc.password }); + setLabel(""); + flash(t("kioskdev_created")); + } catch { + flash(t("common_error")); + } + } + + async function onReset(kioskId: string) { + try { + const r = await reset.mutateAsync(kioskId); + setCreds({ email: "", password: r.password }); + flash(t("kioskdev_reset_done")); + } catch { + flash(t("common_error")); + } + } + + const rows = accounts.data ?? []; + + return ( +
    +

    {t("kioskdev_title")}

    +

    {t("kioskdev_hint")}

    + + {canManage && ( +
    + setLabel(e.target.value)} + /> + +
    + )} + + {creds && ( +
    +

    + {t("kioskdev_creds_hint")} +

    + {creds.email && ( + + )} + +
    + )} + + {rows.length === 0 ? ( +

    + {t("kioskdev_empty")} +

    + ) : ( +
    + + + + + + + {canManage && + + + {rows.map((k) => ( + + + + + {canManage && ( + + )} + + ))} + +
    {t("kioskdev_label")}{t("emp_credentials_email")}{t("emp_status")}} +
    {k.label}{k.email} + + {k.active ? t("status_active") : t("shift_inactive")} + + + +
    +
    + )} + {toast && } +
    + ); +} + +function CredRow({ + label, + value, + onFlash, +}: { + label: string; + value: string; + onFlash: (m: string) => void; +}) { + const { t } = useI18n(); + return ( +
    + {label} + + {value} + + +
    + ); +} diff --git a/web/src/pages/ShiftsPage.tsx b/web/src/pages/ShiftsPage.tsx new file mode 100644 index 0000000..773e0f3 --- /dev/null +++ b/web/src/pages/ShiftsPage.tsx @@ -0,0 +1,449 @@ +import { useMemo, useState } from "react"; +import { + useAssignRoster, + useEmployees, + useRoster, + useSaveShift, + useShifts, +} from "../api/hooks"; +import { useHasPermission } from "../auth/AuthProvider"; +import type { Shift, ShiftWrite } from "../api/types"; +import { useI18n } from "../i18n/LocaleProvider"; +import { Chip, EmptyState, ErrorState, LoadingState, Switch, Toast } from "../ui/components"; + +function isoToday(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( + d.getDate(), + ).padStart(2, "0")}`; +} + +function durationHours(start: string, end: string): number { + const m = (s: string) => Number(s.slice(0, 2)) * 60 + Number(s.slice(3, 5)); + let d = m(end) - m(start); + if (d <= 0) d += 1440; + return Math.round((d / 60) * 10) / 10; +} + +const EMPTY_SHIFT: ShiftWrite = { + name: "", + code: "", + startTime: "08:00", + endTime: "16:00", + breakMinutes: 60, + graceInMinutes: 10, + graceOutMinutes: 10, + active: true, +}; + +export function ShiftsPage() { + const { t, num } = useI18n(); + const can = useHasPermission(); + const canWrite = can("rosters:write"); + const shifts = useShifts(); + const [editing, setEditing] = useState(null); + const [assigning, setAssigning] = useState(false); + const [toast, setToast] = useState(null); + + const flash = (m: string) => { + setToast(m); + window.setTimeout(() => setToast(null), 2500); + }; + + return ( + <> +
    +

    {t("shift_title")}

    + {canWrite && ( + + )} +
    + + {/* Shift definitions */} +
    +

    {t("shift_defs")}

    + {shifts.isLoading ? ( + + ) : shifts.isError ? ( + void shifts.refetch()} /> + ) : (shifts.data?.length ?? 0) === 0 ? ( + + ) : ( +
    + + + + + + + + + {canWrite && + + + {shifts.data!.map((s) => ( + + + + + + + {canWrite && ( + + )} + + ))} + +
    {t("shift_name")}{t("shift_code")}{t("shift_time")}{t("shift_break")}{t("emp_status")}} +
    + {s.name}{" "} + + {s.code} + {num(s.startTime)} – {num(s.endTime)} + {num(s.breakMinutes)} + + {s.active ? t("shift_active") : t("shift_inactive")} + + + +
    +
    + )} +
    + + {/* Daily roster */} + setAssigning(true)} /> + + {editing && ( + setEditing(null)} + onSaved={() => { + setEditing(null); + flash(t("shift_saved")); + }} + /> + )} + {assigning && ( + setAssigning(false)} + onSaved={(count) => { + setAssigning(false); + flash(t("roster_assigned", num(count))); + }} + /> + )} + {toast && } + + ); +} + +function ShiftBadge({ start, end }: { start: string; end: string }) { + const { t, num } = useI18n(); + if (start === end) return {t("shift_24h")}; + if (end <= start) return {t("shift_night")}; + return {t("shift_hours", num(durationHours(start, end)))}; +} + +function RosterCard({ canWrite, onAssign }: { canWrite: boolean; onAssign: () => void }) { + const { t } = useI18n(); + const [date, setDate] = useState(isoToday()); + const roster = useRoster(date); + + return ( +
    +
    +

    {t("roster_title")}

    +
    + setDate(e.target.value)} + /> + {canWrite && ( + + )} +
    +
    + {roster.isLoading ? ( + + ) : roster.isError ? ( + void roster.refetch()} /> + ) : (roster.data?.length ?? 0) === 0 ? ( + + ) : ( +
    + + + + + + + + + {roster.data!.map((r) => ( + + + + + ))} + +
    {t("roster_employee")}{t("roster_shift_col")}
    {r.employeeName}{r.shiftName}
    +
    + )} +
    + ); +} + +function ShiftDialog({ + initial, + shiftId, + onClose, + onSaved, +}: { + initial: ShiftWrite; + shiftId?: string; + onClose: () => void; + onSaved: () => void; +}) { + const { t } = useI18n(); + const save = useSaveShift(); + const [form, setForm] = useState({ + name: initial.name, + code: initial.code, + startTime: initial.startTime, + endTime: initial.endTime, + breakMinutes: initial.breakMinutes, + graceInMinutes: initial.graceInMinutes, + graceOutMinutes: initial.graceOutMinutes, + active: initial.active, + }); + const set = (k: K, v: ShiftWrite[K]) => + setForm((f) => ({ ...f, [k]: v })); + + async function submit() { + if (!form.name.trim() || !form.code.trim()) return; + try { + await save.mutateAsync({ id: shiftId, body: form }); + onSaved(); + } catch { + /* surfaced by the parent toast on next attempt */ + } + } + + return ( +
    +
    e.stopPropagation()}> +

    {shiftId ? t("shift_edit") : t("shift_add")}

    +
    + set("name", v)} /> + set("code", v)} ltr /> + set("startTime", v)} /> + set("endTime", v)} /> + set("breakMinutes", v)} /> + set("graceInMinutes", v)} /> + set("graceOutMinutes", v)} /> +
    +
    +
    + {t("shift_active")} +
    + set("active", v)} label={t("shift_active")} /> +
    +
    + + +
    +
    +
    + ); +} + +function AssignDialog({ + onClose, + onSaved, +}: { + onClose: () => void; + onSaved: (count: number) => void; +}) { + const { t } = useI18n(); + const shifts = useShifts(); + const emps = useEmployees({}); + const assign = useAssignRoster(); + const [shiftId, setShiftId] = useState(""); + const [picked, setPicked] = useState>(new Set()); + const [from, setFrom] = useState(isoToday()); + const [to, setTo] = useState(""); + + const employees = useMemo(() => emps.data?.data ?? [], [emps.data]); + const activeShifts = (shifts.data ?? []).filter((s) => s.active); + + const toggle = (id: string) => { + const next = new Set(picked); + if (next.has(id)) next.delete(id); + else next.add(id); + setPicked(next); + }; + + async function submit() { + if (!shiftId || picked.size === 0) return; + try { + const res = await assign.mutateAsync({ + employeeIds: [...picked], + shiftId, + from, + to: to || undefined, + }); + onSaved(res.created); + } catch { + /* parent toast */ + } + } + + return ( +
    +
    e.stopPropagation()}> +

    {t("roster_assign")}

    + +
    + + +
    + +
    +
    + + setFrom(e.target.value)} /> +
    +
    + + setTo(e.target.value)} /> +
    +
    + +
    + +
    + {employees.map((e) => ( + + ))} +
    +
    + +
    + + +
    +
    +
    + ); +} + +/* ---- small field helpers ---- */ +function Field({ + label, + value, + onChange, + ltr, +}: { + label: string; + value: string; + onChange: (v: string) => void; + ltr?: boolean; +}) { + return ( +
    + + onChange(e.target.value)} + /> +
    + ); +} +function TimeField({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (v: string) => void; +}) { + return ( +
    + + onChange(e.target.value)} /> +
    + ); +} +function NumField({ + label, + value, + onChange, +}: { + label: string; + value: number; + onChange: (v: number) => void; +}) { + return ( +
    + + onChange(Number(e.target.value))} + /> +
    + ); +} diff --git a/web/src/pages/SupportCard.tsx b/web/src/pages/SupportCard.tsx new file mode 100644 index 0000000..f8864ee --- /dev/null +++ b/web/src/pages/SupportCard.tsx @@ -0,0 +1,192 @@ +import { useState } from "react"; +import { useRaiseSupportTicket, useSupportTickets } from "../api/hooks"; +import { Chip, LoadingState } from "../ui/components"; +import { useAuth } from "../auth/AuthProvider"; +import { useI18n } from "../i18n/LocaleProvider"; + +/** + * Who to contact, and the one identifier they will ask for. + * + * A company that has bought WorkTrack otherwise has no way of reaching the + * people they bought it from without leaving the product. The company id + * matters as much as the phone number: it is what a licence is issued against, + * so every support conversation about seats or renewal starts by asking for it. + */ +export function SupportCard() { + const { t } = useI18n(); + const { me } = useAuth(); + const [copied, setCopied] = useState(false); + + async function copyId() { + if (!me?.companyId) return; + try { + await navigator.clipboard.writeText(me.companyId); + setCopied(true); + window.setTimeout(() => setCopied(false), 1800); + } catch { + // Clipboard is blocked in some browsers; the id is on screen to read. + setCopied(false); + } + } + + return ( +
    +

    {t("sup_title")}

    +

    + {t("sup_intro")} +

    + +
    +
    +
    {t("sup_phone")}
    +
    + +93 793 817 977 +
    +
    +
    +
    {t("sup_email")}
    +
    + contact@linumic.com +
    +
    +
    +
    {t("sup_web")}
    +
    + + linumic.com + +
    +
    +
    + + + +
    + {t("sup_company_id")} +
    + + {me?.companyId ?? "—"} + + +
    +

    + {t("sup_company_id_hint")} +

    +
    +
    + ); +} + +/** + * Raising an issue without leaving the product. + * + * The company, plan and seat count travel with it, so the answer does not begin + * with three questions the customer has already been asked once. + */ +function RaiseIssue() { + const { t } = useI18n(); + const tickets = useSupportTickets(); + const raise = useRaiseSupportTicket(); + const [open, setOpen] = useState(false); + const [subject, setSubject] = useState(""); + const [detail, setDetail] = useState(""); + const [sent, setSent] = useState(false); + const [error, setError] = useState(null); + + async function submit() { + setError(null); + if (!subject.trim()) return; + try { + await raise.mutateAsync({ subject: subject.trim(), detail: detail.trim() || undefined }); + setSubject(""); + setDetail(""); + setOpen(false); + setSent(true); + window.setTimeout(() => setSent(false), 5000); + } catch { + setError(t("sup_issue_failed")); + } + } + + const rows = tickets.data ?? []; + + return ( +
    +
    +
    + {t("sup_issues")} +

    {t("sup_issues_hint")}

    +
    + {!open && ( + + )} +
    + + {sent &&

    {t("sup_issue_sent")}

    } + + {open && ( +
    +
    + + setSubject(e.target.value)} + /> +
    +
    + +