Compare commits

...

13 Commits

Author SHA1 Message Date
Trey T 225fb1306b dev: add Kratos + Mailpit local-dev stack
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
docker-compose.dev.yml gains a Kratos identity service (public :4433 / admin
:4434) and a Mailpit SMTP catcher for local onboarding email codes, plus a
postgres-init mount. deploy/local/kratos/ holds the local Kratos config +
identity schema (placeholder dev cookie secret only). Supports the local
backend the XCUITest suite seeds against.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:11:06 -05:00
Trey T b54493f785 backend: GDPR export + retention cleanups + worker metrics (BE-1/2/3)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
BE-3 observability: expose the worker's Prometheus metrics on :6060/metrics
(apns/fcm/asynq histograms + a new cache_ops_total counter were recorded all
along but never scraped — which is why those dashboard panels read empty); add
the worker containerPort, the vmagent worker scrape job, and two additive
NetworkPolicies. Instrument cache Get/Set hit/miss.

BE-2 retention: three periodic Asynq cleanup crons mirroring the reminder-log
cleanup — notifications (90d), webhook dedup log (180d), audit_log (365d).

BE-1 GDPR data export: POST /api/auth/export/ enqueues a low-priority Asynq job
that gathers all of the user's data (owned residences + their tasks/contractors/
documents/share-codes, plus profile/notifications/prefs/push-tokens/subscription/
audit log), zips one JSON file per category, and emails it as an attachment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:15:26 -05:00
Trey T 3b2ea9959a deploy: add node-exporter DaemonSet + vmagent scrape job
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Per-node host metrics (node_filesystem_*, node_memory_*, node_load*) were
missing — a node running out of disk would silently fail the cluster before
any dashboard signal (RUNBOOK §11.1 gap #9). Adds:
- node-exporter DaemonSet (pod-networked, :9100; host /proc,/sys,/ ro) so
  vmagent scrapes it pod-to-pod over the cluster CIDR, independent of node
  public IPs (the netpol node-IP list is OVH-stale).
- two additive NetworkPolicies (default-deny-all is in force): ingress to
  node-exporter from vmagent, and vmagent egress to the pod CIDR on :9100.
- a node-exporter scrape job in the vmagent-config ConfigMap.

Feeds the new "Node host health" row (disk/mem/load) on the eli5 dashboard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:41:40 -05:00
Trey T cf054959bd Auth: require email-verified by default for all app-data routes
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Previously only 2 share-code routes required a verified email; every other
authenticated route (residences, tasks, contractors, documents, notifications,
subscription, users, uploads, media — ~70 routes) accepted an authenticated but
UNVERIFIED user. This inverts the default to verified-by-default.

- router.go: add a `verified` sub-group that applies RequireVerified() ONCE at
  the group level, and move all app-data route setups under it. Verification is
  now the default; new routes are gated automatically. The authenticated-only
  allow-list is just the sign-up surface (/auth/me, /auth/profile, /auth/account).
  Public stays: register, health, webhooks, lookups.
- kratos_auth.go: fix a latent bug the gating exposed — the Redis session cache
  stored the verified flag for 24h, so a user who verified their email mid-session
  was still seen as unverified until the TTL expired (sign up -> verify -> create
  residence would 403). Now only a cached verified=true is trusted (verification
  is sticky); a cached verified=false re-resolves the live status from Kratos.
- auth_safety_test.go: add RequireVerified unit tests (verified passes,
  unverified -> 403, no-user -> 401).

Validated: API gating test (unverified->403, verified->200) + full iOS XCUITest
suite green (211 passed) including the onboarding verify->use-immediately flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:49:37 -05:00
Trey T 12de5a230a i18n: backend-localized lookups, suggestions, and static data (10 languages)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
- suggestion_service: fix scorer (stringList unmarshal accepts scalar|array;
  anchor scoring on base universal score so bool matches no longer tie); add
  localizeReasons for human-readable, Accept-Language-localized match reasons
- lookup_i18n: localize lookup display names, home-profile options, document
  types/categories via internal/i18n
- static_data_handler: per-locale seeded-data response (display_name, home
  profile options, document types/categories) with per-locale cache + ETag
- settings_handler: invalidate per-locale seeded-data cache on lookup change
  instead of pre-warming a single non-localized blob
- cache_service: per-locale seeded-data keys + ETag
- DTOs: add DisplayName fields (task/residence/contractor)
- translations: add suggestion.reason.* and lookup.* keys across all 10 langs
- cmd/api: extract startup helpers + tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:54:54 -05:00
Trey t 25897e913e Auto-verify Sign in with Apple emails
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Apple OIDC mapper now marks the email verified unconditionally via
verified_addresses. SIWA cryptographically proves control of the Apple ID and
Apple owns/verifies the (relay) email, so a code is redundant. Gating on
Apple's `email_verified` claim was unreliable — Apple omits it on many
authorizations, which made verification random (sometimes a surprise code
prompt). Password sign-ups still verify via the honeyDue API flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:30:33 -05:00
Trey t 81e454d86d Add admin-create registration + live email-verified flag
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Registration now goes through POST /api/auth/register, which admin-creates the
Kratos identity (unverified email, NO auto-sent code). Kratos self-service
registration never returns the verification flow id, so the client could never
submit the user's code to the right flow; admin creation lets the client own a
single verification flow instead. Also surface the live Kratos verified flag
and fix Apple audience + team IDs.

- kratos.Client.CreateIdentity via admin API; ErrIdentityExists / ErrInvalidCredentials
- AuthService.Register + AuthHandler.Register + public POST /api/auth/register/
- CurrentUser overrides stale user_profile.verified with the live Kratos flag;
  UserRepository.MarkVerified mirrors it back
- configmap: additional_id_token_audiences allows the .dev bundle id_token
- fix Apple/APNs team id V3PF3M6B6U -> X86BR9WTLD in .env.example + dev init

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:46:30 -05:00
Trey t 7b87f2e392 fix(kratos): drop cloudflare-only middleware on auth ingress
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
iOS Sign In with Apple failed silently — the KMP client never reached
Kratos. Traced to the cloudflare-only Traefik middleware rejecting every
request at the auth ingress.

Root cause: on this cluster klipper-lb sits in front of Traefik and
SNATs the source IP. Traefik's ipAllowList sees the klipper-lb pod IP,
not Cloudflare's real source IP — so even legitimate iOS requests
proxied through Cloudflare get 403'd. The api ingress doesn't have
this middleware (and works correctly), so removing it from auth
matches the working pattern.

Kratos is the user-facing OIDC endpoint — every iOS/web user device
needs to reach it. Cloudflare's edge still does DDoS protection;
Kratos applies its own per-flow rate limits. The IP allowlist was
buying nothing here and breaking everything.

Verified after this change:
  - GET /health/alive → 200
  - GET /health/ready → 200
  - GET /self-service/login/api → 200 + valid flow body listing apple
    as an OIDC provider option

Related but not fixed by this commit: the same klipper-lb SNAT issue
affects admin.myhoneydue.com (which retains cloudflare-only). Admin
basic auth still gates real access there, but the IP check is dead
weight. Proper fix is configuring Traefik ipStrategy to read the
client IP from X-Forwarded-For (set by Cloudflare). Tracked as a
follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:14:35 -05:00
Trey t 6de90acef7 feat(kratos): deploy Ory Kratos to production (Apple-only OIDC)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Auth was structurally broken — the api's Kratos middleware was pointing
at http://kratos:4433 but Kratos wasn't deployed. The only thing keeping
users logged in was a 5-min Redis cache; once it expired the middleware
called Whoami → no DNS → 401 → forced relogin with no path back.

This commit deploys Kratos for real:

Manifests:
  - kratos.yaml + migrate-job.yaml: pin oryd/kratos:v26.2.0@sha256:92eedc...
    (CalVer current stable as of 2026-06-03)
  - configmap.yaml: drop Google OIDC provider (not in scope); fill the
    Apple provider with real Services ID / Team ID / Key ID — Apple now
    sits at providers[0]
  - kratos.yaml: drop the Google-secret env binding; rebind APPLE_PRIVATE_KEY
    to PROVIDERS_0_APPLE_PRIVATE_KEY (shifted from index 1)
  - network-policies.yaml: add a kratos egress rule to allow-egress-from-api.
    Without this, even with kratos running, the api gets "connection refused"
    on http://kratos:4433 (post-DNAT NetworkPolicy enforcement — runbook §9.2).

Operator prerequisites that were completed alongside this commit:
  - Neon kratos database created (separate from honeyDue, owner neondb_owner)
  - Cloudflare DNS for auth.myhoneydue.com (3 A records, proxied)
  - kratos: block added to config.yaml (gitignored): DSN to the Neon DIRECT
    endpoint, cookie + cipher secrets generated, Fastmail SMTPS URI,
    .p8 contents inline

Out of scope intentionally:
  - Google sign-in (additive; can append providers[] later)
  - Migrating existing auth_user rows onto Kratos identities — pre-prod;
    existing users will need to sign in fresh, which creates a new Kratos
    identity and a new local user row (per migration plan in
    manifests/kratos/README.md).

Verified end-to-end:
  - 338 schema migrations applied successfully
  - 2/2 kratos pods Ready
  - api → kratos:4433/sessions/whoami returns 401 for invalid token (was
    "connection refused" before this commit's NetworkPolicy patch)
  - auth.myhoneydue.com resolves through CF; cloudflare-only middleware
    keeps the origin protected exactly like the other hostnames

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:08:09 -05:00
Trey t 64c656bde1 fix(auth): keep users logged in while Kratos is down
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Production is running with no Kratos deployed in-cluster (the deploy
script's kratos-secrets prerequisite isn't satisfied yet — see runbook
§11 #7). That means Whoami calls ALWAYS fail, so any time a user's Redis
session cache expires they get a 401, which the iOS app treats as session
invalid → forced re-login → can't re-authenticate because the same
Whoami is the only way back in.

Two-part mitigation:

1. Bump kratosSessionCacheTTL from 5 minutes to 24 hours. Active users
   stay logged in indefinitely; idle users get bounced after a day.
2. Refresh the cache TTL on every successful cache hit (sliding window)
   so usage-driven expiry is no longer a cliff at the original TTL.

When Kratos actually comes up:
  - revert the TTL constant to a sensible value (1-15 min)
  - the sliding-window refresh is fine to keep; it's good UX regardless

Caveat: this papers over the missing Kratos. New sign-ins still cannot
complete because the api needs Kratos to populate the cache the first
time. Real fix is to deploy Kratos.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 10:48:12 -05:00
Trey t d74cfeee62 feat(subscription): temporarily disable subscription gating
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Subscriptions aren't a shipping feature for now. Make
GET /api/subscription/status/ return a "limitations disabled" / pro-tier
stub at the top of the function with no DB or Redis work:

  - tier="pro"
  - is_active=true
  - limitations_enabled=false  (master kill switch in SubscriptionHelper.kt;
                                every canCreate* check short-circuits true)
  - usage=0 across the board
  - limits map present with empty entries (all-nil = unlimited per the KMM
    model convention) so client tier-lookups don't NPE

The original implementation is preserved verbatim as the unexported
getSubscriptionStatusFromDB method. Re-enabling is a one-line change:
swap GetSubscriptionStatus's body to call s.getSubscriptionStatusFromDB.

Two integration tests in subscription_is_free_test.go assert the original
"limitations actually apply based on settings/IsFree" behavior. They now
t.Skip with the same TEMPORARILY DISABLED marker pointing back to the
service comment. CheckLimit-based tests in the same file still pass
because that codepath is unchanged.

Perf side effect: POST/GET on this route drops to ~1ms (just JSON marshal),
removing 4-5 serial Neon RTTs from every cold call. Was the slowest endpoint
in the live dashboard (~213ms p95 / ~480ms after the pod roll).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 10:07:06 -05:00
Trey t 52bf1ff3c7 perf(task): offload completion notification fan-out to Asynq worker
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
POST /api/task-completions/ was spending ~1.5-1.75s synchronously on
APNs push + SMTP email + B2 image fetches inside sendTaskCompletedNotification.
Per-user loop made it scale linearly with residence membership; one image
attached + one residence user is the 1.75s baseline observed in the live
honeydue-eli5-overview Grafana panel.

Replace the inline call (and the fire-and-forget goroutine in QuickComplete,
which violated the project's "no goroutines in handlers" rule) with an
Asynq job:

  - new task type notification:task_completed (worker/scheduler.go)
  - new payload {task_id, completion_id} — IDs only, worker re-reads
    canonical state from Postgres so concurrent edits between enqueue
    and dequeue are reflected
  - new HandleTaskCompletedNotification on jobs.Handler delegates to
    TaskService.SendTaskCompletedNotificationByID
  - new dispatchTaskCompletedNotification in task_service.go picks
    between enqueue (preferred) and inline (fallback) when Redis is
    unreachable or the enqueuer isn't wired (tests / local dev)

Other changes required to wire it up:

  - widen worker.NewTaskClient signature to accept asynq.RedisClientOpt
    so the file-mounted Redis password (audit HIGH-1) can be supplied;
    no prior callers, no breakage
  - extend worker.Enqueuer interface with EnqueueTaskCompletedNotification
  - add TaskEnqueuer field to router.Dependencies; wire from cmd/api/main.go
    with the standard typed-nil interface guard
  - wire a worker-side TaskService in cmd/worker/main.go so the handler
    can use the shared SendTaskCompletedNotificationByID implementation
    (storage service shared with the existing upload-cleanup wiring)

Expected impact on POST /api/task-completions/ p50:
  ~1.75s -> ~120-170ms (DB + tx + Asynq enqueue only)

Notifications still deliver; they just go via the worker instead of in
the request path. MaxRetry=3; "row not found" returns nil so a deleted
task/completion doesn't churn the retry loop.

All 31 test packages pass. No DB migrations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 09:34:52 -05:00
Trey t e448ec66dc docs(runbook): rewrite for OVH BHS cluster + Tier-3 observability TODOs
Brings the runbook in line with the 2026-06-03 Hetzner → OVH cutover:

- Section 1-5: topology, machines (3x OVH VPS-1 BHS), software versions,
  network/firewall, DNS, filesystem layout — all reflect the live OVH
  install instead of the historical Hetzner setup.
- Section 6: canonical install-from-clean-boxes procedure (the literal
  commands run on 2026-06-03), so anyone can stand up a backup cluster
  by following along.
- Section 9: keeps existing gotchas (vmagent NetPol, token-blown-away,
  healthy-but-empty) and adds four new ones discovered during the OVH
  build: rbac.yaml not in 03-deploy.sh, namespace label missing from api
  metrics (use service="api"), cluster-label collision when two clusters
  push concurrently, worker double-firing on cutover.
- Section 11.1: enumerates Tier-3 observability gaps surfaced while
  building the honeydue-eli5-overview dashboard (node-exporter not
  deployed, Traefik metrics off, push success counters absent, worker
  /metrics endpoint absent, cache hit rate uninstrumented, APNs latency
  uninstrumented).
- Section 12: dated audit trail of cluster changes.

Pure documentation; no code or manifest changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 09:34:35 -05:00
58 changed files with 4094 additions and 1002 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ DEFAULT_FROM_EMAIL=honeyDue <noreply@honeyDue.treytartt.com>
# Release builds: com.myhoneydue.honeyDue
# Debug builds: com.myhoneydue.honeyDue.dev
APPLE_CLIENT_ID=com.myhoneydue.honeyDue.dev
APPLE_TEAM_ID=V3PF3M6B6U
APPLE_TEAM_ID=X86BR9WTLD
# APNs Settings (iOS Push Notifications)
# Direct APNs integration - no external push server needed
+30
View File
@@ -9,6 +9,7 @@ import (
"syscall"
"time"
"github.com/hibiken/asynq"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
@@ -20,6 +21,7 @@ import (
"github.com/treytartt/honeydue-api/internal/router"
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/tracing"
"github.com/treytartt/honeydue-api/internal/worker"
"github.com/treytartt/honeydue-api/pkg/utils"
)
@@ -194,6 +196,28 @@ func main() {
Msg("Push notification client initialized")
}
// Initialize Asynq enqueuer (api-side). Used by services that move
// long-running work off the request path (currently: task-completion
// notification fan-out). Same Redis as cmd/worker — file-mounted password
// applied separately because cfg.Redis.URL does not embed it (audit HIGH-1).
var taskEnqueuer *worker.TaskClient
if redisOpt, parseErr := asynq.ParseRedisURI(cfg.Redis.URL); parseErr != nil {
log.Warn().Err(parseErr).Msg("Failed to parse Redis URL for Asynq enqueuer — completion notifications will run inline")
} else if clientOpt, ok := redisOpt.(asynq.RedisClientOpt); ok {
if cfg.Redis.Password != "" {
clientOpt.Password = cfg.Redis.Password
}
taskEnqueuer = worker.NewTaskClient(clientOpt)
defer func() {
if cerr := taskEnqueuer.Close(); cerr != nil {
log.Warn().Err(cerr).Msg("Failed to close Asynq enqueuer on shutdown")
}
}()
log.Info().Msg("Asynq enqueuer initialized")
} else {
log.Warn().Msg("Redis opt is not RedisClientOpt — Asynq enqueuer skipped; completion notifications will run inline")
}
// Setup router with dependencies (includes admin panel at /admin)
deps := &router.Dependencies{
DB: db,
@@ -205,6 +229,12 @@ func main() {
StorageService: storageService,
MonitoringService: monitoringService,
}
// Only assign the enqueuer when we actually constructed one. Assigning a
// nil *worker.TaskClient directly would create a typed-nil interface that
// fails the `if deps.TaskEnqueuer != nil` check in router.SetupRouter.
if taskEnqueuer != nil {
deps.TaskEnqueuer = taskEnqueuer
}
e := router.SetupRouter(deps)
// Create HTTP server
+32
View File
@@ -0,0 +1,32 @@
package main
import "time"
// shouldInitEmail returns true if email config has host and user set.
func shouldInitEmail(host, user string) bool {
return host != "" && user != ""
}
// shouldInitStorage returns true if upload directory is configured.
func shouldInitStorage(uploadDir string) bool {
return uploadDir != ""
}
// shouldInitEncryption returns true if encryption key is set.
func shouldInitEncryption(encryptionKey string) bool {
return encryptionKey != ""
}
// connectWithRetry attempts a connection with exponential backoff.
// Returns nil on success or the last error after all retries fail.
func connectWithRetry(connect func() error, maxRetries int) error {
var err error
for i := 0; i < maxRetries; i++ {
err = connect()
if err == nil {
return nil
}
time.Sleep(time.Duration(i+1) * time.Millisecond) // use ms in tests
}
return err
}
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"errors"
"testing"
)
// --- shouldInitEmail ---
func TestShouldInitEmail_BothSet_True(t *testing.T) {
if !shouldInitEmail("smtp.example.com", "user@example.com") {
t.Error("expected true when both set")
}
}
func TestShouldInitEmail_MissingHost_False(t *testing.T) {
if shouldInitEmail("", "user@example.com") {
t.Error("expected false when host empty")
}
}
func TestShouldInitEmail_MissingUser_False(t *testing.T) {
if shouldInitEmail("smtp.example.com", "") {
t.Error("expected false when user empty")
}
}
func TestShouldInitEmail_BothEmpty_False(t *testing.T) {
if shouldInitEmail("", "") {
t.Error("expected false when both empty")
}
}
// --- shouldInitStorage ---
func TestShouldInitStorage_Set_True(t *testing.T) {
if !shouldInitStorage("/uploads") {
t.Error("expected true")
}
}
func TestShouldInitStorage_Empty_False(t *testing.T) {
if shouldInitStorage("") {
t.Error("expected false")
}
}
// --- shouldInitEncryption ---
func TestShouldInitEncryption_Set_True(t *testing.T) {
if !shouldInitEncryption("secret-key-123") {
t.Error("expected true")
}
}
func TestShouldInitEncryption_Empty_False(t *testing.T) {
if shouldInitEncryption("") {
t.Error("expected false")
}
}
// --- connectWithRetry ---
func TestConnectWithRetry_SucceedsFirst_NoRetry(t *testing.T) {
calls := 0
err := connectWithRetry(func() error {
calls++
return nil
}, 3)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if calls != 1 {
t.Errorf("calls = %d, want 1", calls)
}
}
func TestConnectWithRetry_SucceedsSecond_OneRetry(t *testing.T) {
calls := 0
err := connectWithRetry(func() error {
calls++
if calls == 1 {
return errors.New("fail")
}
return nil
}, 3)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if calls != 2 {
t.Errorf("calls = %d, want 2", calls)
}
}
func TestConnectWithRetry_AllFail_ReturnsError(t *testing.T) {
calls := 0
err := connectWithRetry(func() error {
calls++
return errors.New("fail")
}, 3)
if err == nil {
t.Error("expected error")
}
if calls != 3 {
t.Errorf("calls = %d, want 3", calls)
}
}
+56 -4
View File
@@ -23,6 +23,7 @@ import (
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/tracing"
"github.com/treytartt/honeydue-api/internal/worker"
"github.com/treytartt/honeydue-api/internal/worker/jobs"
"github.com/treytartt/honeydue-api/pkg/utils"
)
@@ -180,11 +181,15 @@ func main() {
// Create job handler
jobHandler := jobs.NewHandler(db, pushClient, emailService, notificationService, cfg)
// Wire upload service for the pending_uploads cleanup cron. Storage may
// be local-disk (no S3 backend), in which case the upload service stays
// nil and the cleanup handler no-ops. Cache is optional — the cleanup
// path doesn't rate-limit and works fine with a nil cache.
// Wire upload service for the pending_uploads cleanup cron AND share the
// underlying storage service with the TaskService below so the worker can
// load completion images for email embedding. Storage may be local-disk
// (no S3 backend), in which case the upload service stays nil and the
// cleanup handler no-ops. Cache is optional — the cleanup path doesn't
// rate-limit and works fine with a nil cache.
var sharedStorageService *services.StorageService
if storageService, sErr := services.NewStorageService(&cfg.Storage); sErr == nil {
sharedStorageService = storageService
if s3 := storageService.S3Backend(); s3 != nil {
pendingUploadRepo := repositories.NewPendingUploadRepository(db)
uploadService := services.NewUploadService(pendingUploadRepo, s3, &cfg.Storage, nil)
@@ -194,6 +199,25 @@ func main() {
log.Warn().Err(sErr).Msg("Failed to initialize storage service for upload cleanup; cleanup cron will no-op")
}
// Wire a TaskService for the task-completed notification handler. The
// worker re-creates this (vs. importing the api's wired instance) because
// each binary owns its own dependency graph. The handler is fully nil-safe
// — if any of the wired services are absent, the corresponding side of
// notification delivery (push or email) is skipped.
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
workerTaskService := services.NewTaskService(taskRepo, residenceRepo)
if notificationService != nil {
workerTaskService.SetNotificationService(notificationService)
}
if emailService != nil {
workerTaskService.SetEmailService(emailService)
}
if sharedStorageService != nil {
workerTaskService.SetStorageService(sharedStorageService)
}
jobHandler.SetTaskService(workerTaskService)
// Create Asynq mux and register handlers
mux := asynq.NewServeMux()
@@ -208,6 +232,11 @@ func main() {
mux.HandleFunc(jobs.TypeOnboardingEmails, jobHandler.HandleOnboardingEmails)
mux.HandleFunc(jobs.TypeReminderLogCleanup, jobHandler.HandleReminderLogCleanup)
mux.HandleFunc(jobs.TypeUploadCleanup, jobHandler.HandleUploadCleanup)
mux.HandleFunc(jobs.TypeNotificationCleanup, jobHandler.HandleNotificationCleanup)
mux.HandleFunc(jobs.TypeWebhookLogCleanup, jobHandler.HandleWebhookLogCleanup)
mux.HandleFunc(jobs.TypeAuditLogCleanup, jobHandler.HandleAuditLogCleanup)
mux.HandleFunc(worker.TypeTaskCompletedNotification, jobHandler.HandleTaskCompletedNotification)
mux.HandleFunc(worker.TypeDataExport, jobHandler.HandleDataExport)
// Register email job handlers (welcome, verification, password reset, password changed)
if emailService != nil {
@@ -256,6 +285,23 @@ func main() {
}
log.Info().Str("cron", "30 * * * *").Msg("Registered pending_uploads cleanup job (runs hourly)")
// Data-retention cleanups (BE-2). Staggered off the 3:00 reminder cleanup to
// avoid piling DELETEs onto the same Neon connection window.
if _, err := scheduler.Register("0 2 * * *", asynq.NewTask(jobs.TypeNotificationCleanup, nil)); err != nil {
log.Fatal().Err(err).Msg("Failed to register notification cleanup job")
}
log.Info().Str("cron", "0 2 * * *").Msg("Registered notification cleanup job (daily 02:00 UTC, 90d retention)")
if _, err := scheduler.Register("30 2 * * 0", asynq.NewTask(jobs.TypeWebhookLogCleanup, nil)); err != nil {
log.Fatal().Err(err).Msg("Failed to register webhook log cleanup job")
}
log.Info().Str("cron", "30 2 * * 0").Msg("Registered webhook log cleanup job (weekly Sun 02:30 UTC, 180d retention)")
if _, err := scheduler.Register("30 3 * * 0", asynq.NewTask(jobs.TypeAuditLogCleanup, nil)); err != nil {
log.Fatal().Err(err).Msg("Failed to register audit log cleanup job")
}
log.Info().Str("cron", "30 3 * * 0").Msg("Registered audit log cleanup job (weekly Sun 03:30 UTC, 365d retention)")
// Handle graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
@@ -267,6 +313,12 @@ func main() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
// Expose Prometheus metrics so vmagent can scrape the worker. The
// apns_send_*, fcm_send_*, asynq_job_* and cache_ops_* series have been
// recorded on this process all along — they were just never exposed, which
// is why those dashboard panels read empty. Same :6060 as health; in-cluster
// only (not externally published).
healthMux.Handle("/metrics", prom.HTTPHandler())
healthSrv := &http.Server{
Addr: workerHealthAddr,
Handler: healthMux,
+1 -1
View File
@@ -92,7 +92,7 @@ ADMIN_PW="$(openssl rand -base64 16)"
EMAIL_USER="treytartt@fastmail.com"
APNS_KEY_ID="9R5Q7ZX874"
APNS_TEAM_ID="V3PF3M6B6U"
APNS_TEAM_ID="X86BR9WTLD"
log ""
log "Pre-filled from existing dev server:"
+895 -191
View File
File diff suppressed because it is too large Load Diff
+42 -18
View File
@@ -5,9 +5,10 @@
# kratos-secrets Secret (see kratos.yaml). Kratos is configured natively via
# env vars, so this is the idiomatic split — only non-secret config here.
#
# OPERATOR: replace the GOOGLE_OAUTH_CLIENT_ID / APPLE_* client-id placeholders
# below with the real (non-secret) OAuth client identifiers once the Apple and
# Google OAuth apps exist. The matching secrets go in kratos-secrets.
# OIDC scope: Apple-only as of 2026-06-03. Google is intentionally absent;
# adding it later is additive — append a `- id: google` block under
# selfservice.methods.oidc.config.providers (it becomes index 1) and bind a
# matching CLIENT_SECRET env in kratos.yaml.
apiVersion: v1
kind: ConfigMap
metadata:
@@ -18,9 +19,9 @@ metadata:
app.kubernetes.io/part-of: honeydue
data:
kratos.yml: |
# version must track the Kratos image tag — confirm against the deployed
# Kratos release (Ory uses CalVer, e.g. v26.x). See kratos/README.md.
version: v1.3.0
# version must track the Kratos image tag — kratos.yaml + migrate-job.yaml
# both pin oryd/kratos:v26.2.0 (2026-06-03). See kratos/README.md.
version: v1.3.0 # internal config schema version; do not change unless Kratos release notes require it
serve:
public:
@@ -57,20 +58,27 @@ data:
enabled: true
config:
providers:
# index 0 — Google. client_secret is injected via env var
# SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_0_CLIENT_SECRET.
- id: google
provider: google
client_id: GOOGLE_OAUTH_CLIENT_ID
mapper_url: file:///etc/kratos/oidc.google.jsonnet
scope: [openid, email, profile]
# index 1 — Apple. apple_private_key is injected via env var
# SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_1_APPLE_PRIVATE_KEY.
# index 0 — Apple Sign In. apple_private_key (.p8 contents) is
# injected via env SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_0_APPLE_PRIVATE_KEY.
# client_id is the Apple Services ID (here: the bundle ID, which
# was configured as a Services ID with Sign In with Apple
# capability — see operator notes in README.md §5).
- id: apple
provider: apple
client_id: APPLE_SERVICES_ID
apple_team_id: APPLE_TEAM_ID
apple_private_key_id: APPLE_PRIVATE_KEY_ID
# Production bundle id. Apple issues id_tokens with
# `aud` = the requesting app's bundle id, so this is the
# primary audience Kratos verifies against.
client_id: com.myhoneydue.honeyDue
# Debug builds out of Xcode use a `.dev` bundle id (see
# iosApp/honeyDue.xcodeproj — Debug config). Their id_tokens
# therefore have `aud: com.myhoneydue.honeyDue.dev`, which
# the primary client_id check rejects. Whitelist the dev
# audience so Apple Sign In works from a non-Release Xcode
# build without per-build Kratos reconfiguration.
additional_id_token_audiences:
- com.myhoneydue.honeyDue.dev
apple_team_id: X86BR9WTLD
apple_private_key_id: HQD3NCF99C
mapper_url: file:///etc/kratos/oidc.apple.jsonnet
scope: [openid, email, name]
@@ -198,11 +206,27 @@ data:
// Maps Apple OIDC claims onto the honeyDue identity schema. Apple only
// returns the name on the very first authorization and not in the ID
// token claims, so only email is mapped here.
//
// Sign in with Apple emails are marked verified UNCONDITIONALLY: completing
// SIWA cryptographically proves the user controls that Apple ID, and Apple
// owns/verifies the (relay or real) email, so a 6-digit code would be
// redundant. We deliberately do NOT gate this on Apple's `email_verified`
// claim — Apple omits that claim on many authorizations (only sends it on
// the first grant), which made auto-verification random: sometimes verified,
// sometimes a surprise code prompt (observed 2026-06-03). Marking it
// verified on every SIWA makes the behaviour consistent: Apple users never
// see a code; password sign-ups still verify via the honeyDue API flow.
local claims = std.extVar('claims');
{
identity: {
traits: {
email: claims.email,
},
verified_addresses: std.prune([
if 'email' in claims then {
via: 'email',
value: claims.email,
},
]),
},
}
+12 -6
View File
@@ -1,10 +1,16 @@
# Public ingress for Ory Kratos — auth.myhoneydue.com → Kratos public API :4433.
#
# Chains the same edge middlewares as the honeyDue API ingress: cloudflare-only
# (reject non-Cloudflare source IPs), security-headers, and the general
# rate-limit. Kratos's self-service flows are multi-request, so the strict
# auth-rate-limit (5/min) is intentionally NOT used here — Kratos applies its
# own per-flow protections.
# Middlewares match the honeyDue API ingress (security-headers + rate-limit).
# The cloudflare-only middleware is intentionally NOT applied here: on this
# cluster, klipper-lb SNATs the source IP before Traefik sees it, so
# cloudflare-only's IP allowlist rejects every legitimate Cloudflare request
# (verified 2026-06-03 — iOS Apple Sign In failed silently because Kratos
# never received the request). The api ingress doesn't use cloudflare-only
# for the same reason. DDoS protection still rides on Cloudflare's edge.
#
# Kratos's self-service flows are multi-request, so the strict auth-rate-limit
# (5/min) is intentionally NOT used here — Kratos applies its own per-flow
# protections.
#
# OPERATOR: confirm the cloudflare-origin-cert TLS secret covers
# auth.myhoneydue.com (apex + wildcard origin cert), and add the
@@ -18,7 +24,7 @@ metadata:
app.kubernetes.io/name: kratos
app.kubernetes.io/part-of: honeydue
annotations:
traefik.ingress.kubernetes.io/router.middlewares: honeydue-cloudflare-only@kubernetescrd,honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd
traefik.ingress.kubernetes.io/router.middlewares: honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd
spec:
ingressClassName: traefik
tls:
+14 -13
View File
@@ -1,14 +1,17 @@
# Ory Kratos — identity service for honeyDue.
#
# Deployed only once the operator has completed the prerequisites in
# kratos/README.md (Neon `kratos` database, auth.myhoneydue.com DNS, Apple +
# Google OAuth apps, and the kratos-secrets Secret). Until then 03-deploy.sh
# skips the Kratos apply, so the existing stack is unaffected.
# Deployed once the operator has completed the prerequisites in kratos/README.md
# (Neon `kratos` database, auth.myhoneydue.com DNS, Apple Sign In OIDC client,
# and the kratos-secrets Secret). Until then 03-deploy.sh skips the Kratos
# apply, so the existing stack is unaffected.
#
# IMAGE: oryd/kratos uses CalVer (v25.x / v26.x). The tag below is a
# fail-loud placeholder — set the current stable tag and pin a @sha256:
# digest (like redis/vmagent) before deploying. See kratos/README.md.
# The schema-migration Job is in migrate-job.yaml (run before this).
# IMAGE: pinned to oryd/kratos v26.2.0 (CalVer current stable as of 2026-06-03)
# with the linux/amd64 digest. The schema-migration Job is in migrate-job.yaml
# and runs before this Deployment rolls.
#
# OIDC: currently Apple-only (configmap.yaml providers[0]). Google was scoped
# out at deploy time; adding it later is additive — append to providers[] in
# configmap.yaml and add the matching CLIENT_SECRET env binding here.
---
apiVersion: apps/v1
kind: Deployment
@@ -41,7 +44,7 @@ spec:
type: RuntimeDefault
containers:
- name: kratos
image: oryd/kratos:REPLACE_WITH_CURRENT_STABLE_TAG
image: oryd/kratos:v26.2.0@sha256:92eedc292ff8e1a918ac442c88ed0abe44610c75121700963114549908a45ac3
imagePullPolicy: IfNotPresent
args:
- serve
@@ -65,10 +68,8 @@ spec:
- name: COURIER_SMTP_CONNECTION_URI
valueFrom: { secretKeyRef: { name: kratos-secrets, key: smtp_connection_uri } }
# OIDC provider secrets — index must match the providers list
# order in configmap.yaml (0 = google, 1 = apple).
- name: SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_0_CLIENT_SECRET
valueFrom: { secretKeyRef: { name: kratos-secrets, key: google_client_secret } }
- name: SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_1_APPLE_PRIVATE_KEY
# order in configmap.yaml. Apple-only for now (index 0).
- name: SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_0_APPLE_PRIVATE_KEY
valueFrom: { secretKeyRef: { name: kratos-secrets, key: apple_private_key } }
volumeMounts:
- name: config
+3 -3
View File
@@ -2,8 +2,8 @@
# database before the Kratos Deployment rolls. 03-deploy.sh applies this,
# waits for completion, then applies kratos.yaml.
#
# IMAGE: set the same oryd/kratos tag as kratos.yaml (Ory CalVer v25.x/v26.x);
# pin a @sha256: digest. See kratos/README.md.
# IMAGE: pinned to oryd/kratos v26.2.0 (CalVer current stable as of 2026-06-03)
# with the linux/amd64 digest. Bump in sync with kratos.yaml's image.
apiVersion: batch/v1
kind: Job
metadata:
@@ -28,7 +28,7 @@ spec:
type: RuntimeDefault
containers:
- name: kratos-migrate
image: oryd/kratos:REPLACE_WITH_CURRENT_STABLE_TAG
image: oryd/kratos:v26.2.0@sha256:92eedc292ff8e1a918ac442c88ed0abe44610c75121700963114549908a45ac3
imagePullPolicy: IfNotPresent
args: ["migrate", "sql", "-e", "--yes"]
env:
@@ -140,6 +140,20 @@ spec:
ports:
- protocol: TCP
port: 6379
# Kratos (in-cluster). The auth middleware validates every session via
# http://kratos:4433/sessions/whoami; the AuthService also uses :4434
# for account deletion (DELETE /admin/identities/{id}). k3s evaluates
# egress rules AFTER kube-proxy DNAT (runbook §9.2), so this podSelector
# rule covers Service ClusterIP traffic correctly.
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: kratos
ports:
- protocol: TCP
port: 4433
- protocol: TCP
port: 4434
# External services: Neon DB (5432), SMTP (587), HTTPS (443 — APNs, FCM, B2, PostHog)
- to:
- ipBlock:
@@ -0,0 +1,126 @@
# node-exporter — per-node host metrics (filesystem, memory, load, CPU).
# Runs as a normal pod (NOT hostNetwork) so vmagent scrapes it pod-to-pod over
# the cluster CIDR, avoiding any dependency on node public IPs (the netpol
# node-IP list is OVH-stale). Host /proc, /sys and / are bind-mounted read-only
# so the filesystem/memory/load collectors read the real host, not the pod ns.
# Added 2026-06-08 to close RUNBOOK §11.1 gap #9 (node disk/mem were unmonitored).
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: node-exporter
namespace: honeydue
labels:
app.kubernetes.io/name: node-exporter
app.kubernetes.io/part-of: honeydue
spec:
selector:
matchLabels:
app.kubernetes.io/name: node-exporter
template:
metadata:
labels:
app.kubernetes.io/name: node-exporter
app.kubernetes.io/part-of: honeydue
spec:
# Run on every node, including any tainted control-plane nodes.
tolerations:
- operator: Exists
securityContext:
runAsNonRoot: true
runAsUser: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: node-exporter
image: quay.io/prometheus/node-exporter:v1.8.2 # TODO digest-pin (audit K3S-F14)
imagePullPolicy: IfNotPresent
args:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/host/root
# Only report real host mounts; drop the kubelet/container churn.
- --collector.filesystem.mount-points-exclude=^/(dev|proc|sys|run|var/lib/kubelet/.+|var/lib/docker/.+|var/lib/containerd/.+)($|/)
- --collector.filesystem.fs-types-exclude=^(autofs|binfmt_misc|bpf|cgroup2?|configfs|debugfs|devpts|devtmpfs|fusectl|hugetlbfs|iso9660|mqueue|nsfs|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|selinuxfs|squashfs|sysfs|tracefs)$
- --no-collector.wifi
- --no-collector.hwmon
- --web.listen-address=:9100
ports:
- name: metrics
containerPort: 9100
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: 30m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
volumeMounts:
- name: proc
mountPath: /host/proc
readOnly: true
- name: sys
mountPath: /host/sys
readOnly: true
- name: root
mountPath: /host/root
mountPropagation: HostToContainer
readOnly: true
volumes:
- name: proc
hostPath:
path: /proc
- name: sys
hostPath:
path: /sys
- name: root
hostPath:
path: /
---
# default-deny-all blocks ingress; allow vmagent to scrape :9100.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-to-node-exporter
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: node-exporter
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: vmagent
ports:
- port: 9100
protocol: TCP
---
# vmagent's existing egress policy only opens :8000/:8080 to the pod CIDR.
# Additive policy (NetworkPolicies are OR'd) opening :9100 for the node-exporter
# scrape — leaves the working allow-egress-from-vmagent policy untouched.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-from-vmagent-to-node-exporter
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: vmagent
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 10.42.0.0/16
ports:
- port: 9100
protocol: TCP
+40 -12
View File
@@ -57,18 +57,46 @@ data:
action: keep
regex: http-metrics
# honeyDue worker — also exposes /metrics if/when we add it.
# Keep this stanza commented until the worker has a /metrics endpoint;
# uncommented form drops scrapes silently.
# - job_name: worker
# kubernetes_sd_configs:
# - role: pod
# namespaces:
# names: [honeydue]
# relabel_configs:
# - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
# action: keep
# regex: worker
# node-exporter — per-node host metrics (node_filesystem_*, node_memory_*,
# node_load*). Pod-networked DaemonSet scraped on :9100 over the pod CIDR.
- job_name: node-exporter
kubernetes_sd_configs:
- role: pod
namespaces:
names: [honeydue]
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
action: keep
regex: node-exporter
- source_labels: [__meta_kubernetes_pod_container_port_number]
action: keep
regex: "9100"
- source_labels: [__meta_kubernetes_pod_name]
target_label: pod
- source_labels: [__meta_kubernetes_pod_node_name]
target_label: node
- target_label: service
replacement: node-exporter
# honeyDue worker — exposes /metrics on :6060 (apns/fcm/asynq/cache series).
- job_name: worker
kubernetes_sd_configs:
- role: pod
namespaces:
names: [honeydue]
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
action: keep
regex: worker
- source_labels: [__meta_kubernetes_pod_container_port_number]
action: keep
regex: "6060"
- source_labels: [__meta_kubernetes_pod_name]
target_label: pod
- source_labels: [__meta_kubernetes_pod_node_name]
target_label: node
- target_label: service
replacement: worker
---
apiVersion: v1
@@ -43,6 +43,11 @@ spec:
- name: worker
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
ports:
# health + Prometheus /metrics (in-cluster only; scraped by vmagent)
- name: metrics
containerPort: 6060
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@@ -95,3 +100,46 @@ spec:
- name: tmp
emptyDir:
sizeLimit: 64Mi
---
# Allow vmagent to scrape the worker's /metrics on :6060 (default-deny-all is in
# force; the worker otherwise receives no ingress). Additive — see node-exporter.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-to-worker-metrics
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: worker
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: vmagent
ports:
- port: 6060
protocol: TCP
---
# vmagent's base egress policy only opens :8000/:8080 to the pod CIDR; this
# additive policy opens :6060 for the worker scrape (leaves the base untouched).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-from-vmagent-to-worker
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: vmagent
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 10.42.0.0/16
ports:
- port: 6060
protocol: TCP
+39
View File
@@ -0,0 +1,39 @@
{
"$id": "https://honeydue.app/identity.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "honeyDue user",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "Email",
"minLength": 3,
"maxLength": 320,
"ory.sh/kratos": {
"credentials": {
"password": { "identifier": true },
"code": { "identifier": true, "via": "email" },
"totp": { "account_name": true }
},
"verification": { "via": "email" },
"recovery": { "via": "email" }
}
},
"name": {
"type": "object",
"title": "Name",
"properties": {
"first": { "type": "string", "title": "First name", "maxLength": 100 },
"last": { "type": "string", "title": "Last name", "maxLength": 100 }
}
}
},
"required": ["email"],
"additionalProperties": false
}
}
}
+101
View File
@@ -0,0 +1,101 @@
version: v1.3.0
serve:
public:
base_url: http://localhost:4433/
cors:
enabled: true
allowed_origins:
- http://localhost
- http://localhost:3000
- http://localhost:8000
- http://127.0.0.1
allowed_methods: [GET, POST, PUT, PATCH, DELETE, OPTIONS]
allowed_headers: [Authorization, Content-Type, X-Session-Token, Cookie]
exposed_headers: [Content-Type, Set-Cookie]
allow_credentials: true
admin:
base_url: http://kratos:4434/
selfservice:
default_browser_return_url: http://localhost:8000/
allowed_return_urls:
- http://localhost:8000
- honeydue://callback
methods:
password:
enabled: true
config:
min_password_length: 8
identifier_similarity_check_enabled: false
code:
enabled: true
oidc:
enabled: false
flows:
error:
ui_url: http://localhost:8000/auth/error
login:
ui_url: http://localhost:8000/auth/login
lifespan: 10m
registration:
ui_url: http://localhost:8000/auth/registration
lifespan: 10m
after:
password:
hooks:
- hook: session
verification:
enabled: true
ui_url: http://localhost:8000/auth/verification
use: code
after:
default_browser_return_url: http://localhost:8000/
recovery:
enabled: true
ui_url: http://localhost:8000/auth/recovery
use: code
settings:
ui_url: http://localhost:8000/auth/settings
privileged_session_max_age: 15m
logout:
after:
default_browser_return_url: http://localhost:8000/
log:
level: debug
format: text
leak_sensitive_values: true
secrets:
cookie:
- local-dev-cookie-secret-please-change-this-32chars
cipher:
- 0123456789abcdef0123456789abcdef
ciphers:
algorithm: xchacha20-poly1305
hashers:
algorithm: bcrypt
bcrypt:
cost: 8
identity:
default_schema_id: honeydue
schemas:
- id: honeydue
url: file:///etc/config/kratos/identity.schema.json
courier:
smtp:
connection_uri: smtp://mailpit:1025/?disable_starttls=true
from_address: noreply@localhost
from_name: honeyDue Local
session:
lifespan: 720h
cookie:
same_site: Lax
+60
View File
@@ -14,6 +14,7 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-honeydue}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./deploy/local/postgres-init:/docker-entrypoint-initdb.d:ro
ports:
- "${DB_PORT:-5433}:5432" # 5433 externally to avoid conflicts with local postgres
healthcheck:
@@ -91,6 +92,10 @@ services:
# Storage encryption
STORAGE_ENCRYPTION_KEY: ${STORAGE_ENCRYPTION_KEY}
# Kratos (identity service)
KRATOS_PUBLIC_URL: "http://kratos:4433"
KRATOS_ADMIN_URL: "http://kratos:4434"
volumes:
- ./push_certs:/certs:ro
- ./uploads:/app/uploads
@@ -99,6 +104,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
kratos:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/api/health/"]
interval: 30s
@@ -184,6 +191,59 @@ services:
networks:
- honeydue-network
# Mailpit — local SMTP catcher (for Kratos email codes during onboarding)
mailpit:
image: axllent/mailpit:latest
container_name: honeydue-mailpit
restart: unless-stopped
ports:
- "${MAILPIT_SMTP_PORT:-1025}:1025"
- "${MAILPIT_HTTP_PORT:-8025}:8025"
networks:
- honeydue-network
# Kratos schema migration (one-shot, runs before kratos starts)
kratos-migrate:
image: oryd/kratos:v1.3.0
container_name: honeydue-kratos-migrate
command: ["migrate", "sql", "-e", "--yes"]
environment:
DSN: "postgres://${POSTGRES_USER:-honeydue}:${POSTGRES_PASSWORD:-honeydue_dev_password}@db:5432/kratos?sslmode=disable"
depends_on:
db:
condition: service_healthy
networks:
- honeydue-network
restart: "no"
# Ory Kratos — identity service
kratos:
image: oryd/kratos:v1.3.0
container_name: honeydue-kratos
restart: unless-stopped
command: ["serve", "--config", "/etc/config/kratos/kratos.yml", "--watch-courier", "--dev"]
ports:
- "${KRATOS_PUBLIC_PORT:-4433}:4433"
- "${KRATOS_ADMIN_PORT:-4434}:4434"
environment:
DSN: "postgres://${POSTGRES_USER:-honeydue}:${POSTGRES_PASSWORD:-honeydue_dev_password}@db:5432/kratos?sslmode=disable"
LOG_LEVEL: "debug"
volumes:
- ./deploy/local/kratos:/etc/config/kratos:ro
depends_on:
kratos-migrate:
condition: service_completed_successfully
mailpit:
condition: service_started
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:4434/health/ready"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
networks:
- honeydue-network
# Dozzle — lightweight real-time log viewer
dozzle:
image: amir20/dozzle:latest
+14 -131
View File
@@ -248,137 +248,20 @@ func (h *AdminSettingsHandler) cacheAllLookups(ctx context.Context) (bool, error
}
log.Debug().Int("count", len(taskTemplates)).Msg("Cached task templates")
// Build and cache the unified seeded data response
// Import the grouped response type
seededData := map[string]interface{}{
"residence_types": residenceTypes,
"task_categories": categories,
"task_priorities": priorities,
"task_frequencies": frequencies,
"contractor_specialties": specialties,
"task_templates": buildGroupedTemplates(taskTemplates),
// Invalidate the unified seeded-data cache for every locale. The combined
// response is localized (lookup display_name + home-profile options) and is
// rebuilt per-locale on demand by the static_data handler, so the correct
// action after a lookup change is to clear all language variants rather than
// pre-warm a single (non-localized) blob.
if err := cache.InvalidateSeededData(ctx); err != nil {
return false, fmt.Errorf("failed to invalidate seeded data: %w", err)
}
etag, err := cache.CacheSeededData(ctx, seededData)
if err != nil {
return false, fmt.Errorf("failed to cache seeded data: %w", err)
}
log.Debug().Str("etag", etag).Msg("Cached unified seeded data")
log.Debug().Msg("Invalidated per-locale seeded data cache")
log.Info().Msg("All lookup data cached in Redis successfully")
return true, nil
}
// buildGroupedTemplates groups task templates by category for the seeded data response
func buildGroupedTemplates(templates []models.TaskTemplate) map[string]interface{} {
type templateResponse struct {
ID uint `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
CategoryID *uint `json:"category_id"`
Category map[string]interface{} `json:"category,omitempty"`
FrequencyID *uint `json:"frequency_id"`
Frequency map[string]interface{} `json:"frequency,omitempty"`
IconIOS string `json:"icon_ios"`
IconAndroid string `json:"icon_android"`
Tags []string `json:"tags"`
DisplayOrder int `json:"display_order"`
IsActive bool `json:"is_active"`
}
type categoryGroup struct {
CategoryName string `json:"category_name"`
CategoryID *uint `json:"category_id"`
Templates []templateResponse `json:"templates"`
Count int `json:"count"`
}
categoryMap := make(map[string]*categoryGroup)
categoryOrder := []string{}
for _, t := range templates {
categoryName := "Uncategorized"
var categoryID *uint
if t.Category != nil {
categoryName = t.Category.Name
categoryID = &t.Category.ID
}
if _, exists := categoryMap[categoryName]; !exists {
categoryMap[categoryName] = &categoryGroup{
CategoryName: categoryName,
CategoryID: categoryID,
Templates: []templateResponse{},
}
categoryOrder = append(categoryOrder, categoryName)
}
resp := templateResponse{
ID: t.ID,
Title: t.Title,
Description: t.Description,
CategoryID: t.CategoryID,
FrequencyID: t.FrequencyID,
IconIOS: t.IconIOS,
IconAndroid: t.IconAndroid,
Tags: parseTags(t.Tags),
DisplayOrder: t.DisplayOrder,
IsActive: t.IsActive,
}
if t.Category != nil {
resp.Category = map[string]interface{}{
"id": t.Category.ID,
"name": t.Category.Name,
"description": t.Category.Description,
"icon": t.Category.Icon,
"color": t.Category.Color,
"display_order": t.Category.DisplayOrder,
}
}
if t.Frequency != nil {
resp.Frequency = map[string]interface{}{
"id": t.Frequency.ID,
"name": t.Frequency.Name,
"days": t.Frequency.Days,
"display_order": t.Frequency.DisplayOrder,
}
}
categoryMap[categoryName].Templates = append(categoryMap[categoryName].Templates, resp)
}
categories := make([]categoryGroup, len(categoryOrder))
totalCount := 0
for i, name := range categoryOrder {
group := categoryMap[name]
group.Count = len(group.Templates)
totalCount += group.Count
categories[i] = *group
}
return map[string]interface{}{
"categories": categories,
"total_count": totalCount,
}
}
// parseTags splits a comma-separated tags string into a slice
func parseTags(tags string) []string {
if tags == "" {
return []string{}
}
parts := strings.Split(tags, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// SeedTestData handles POST /api/admin/settings/seed-test-data
func (h *AdminSettingsHandler) SeedTestData(c echo.Context) error {
if err := h.runSeedFile("002_test_data.sql"); err != nil {
@@ -518,9 +401,9 @@ type ClearAllDataResponse struct {
// ClearStuckJobsResponse represents the response after clearing stuck Redis jobs
type ClearStuckJobsResponse struct {
Message string `json:"message"`
KeysDeleted int `json:"keys_deleted"`
DeletedKeys []string `json:"deleted_keys"`
Message string `json:"message"`
KeysDeleted int `json:"keys_deleted"`
DeletedKeys []string `json:"deleted_keys"`
}
// ClearStuckJobs handles POST /api/admin/settings/clear-stuck-jobs
@@ -538,9 +421,9 @@ func (h *AdminSettingsHandler) ClearStuckJobs(c echo.Context) error {
// Patterns for asynq job keys that can get stuck
patterns := []string{
"asynq:{default}:retry", // Retry queue
"asynq:{default}:archived", // Archived/dead jobs
"asynq:{default}:t:*", // Individual task metadata
"asynq:{default}:retry", // Retry queue
"asynq:{default}:archived", // Archived/dead jobs
"asynq:{default}:t:*", // Individual task metadata
}
for _, pattern := range patterns {
+5 -2
View File
@@ -8,8 +8,11 @@ import (
// ContractorSpecialtyResponse represents a contractor specialty
type ContractorSpecialtyResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
ID uint `json:"id"`
// Name is the stable English identifier (clients match on this).
Name string `json:"name"`
// DisplayName is the localized label for the request's Accept-Language.
DisplayName string `json:"display_name"`
Description string `json:"description"`
Icon string `json:"icon"`
DisplayOrder int `json:"display_order"`
+4 -1
View File
@@ -10,8 +10,11 @@ import (
// ResidenceTypeResponse represents a residence type in the API response
type ResidenceTypeResponse struct {
ID uint `json:"id"`
ID uint `json:"id"`
// Name is the stable English identifier (clients match on this).
Name string `json:"name"`
// DisplayName is the localized label for the request's Accept-Language.
DisplayName string `json:"display_name"`
}
// ResidenceUserResponse represents a user with access to a residence
+57 -52
View File
@@ -13,8 +13,11 @@ import (
// TaskCategoryResponse represents a task category
type TaskCategoryResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
ID uint `json:"id"`
// Name is the stable English identifier (clients match on this).
Name string `json:"name"`
// DisplayName is the localized label for the request's Accept-Language.
DisplayName string `json:"display_name"`
Description string `json:"description"`
Icon string `json:"icon"`
Color string `json:"color"`
@@ -25,6 +28,7 @@ type TaskCategoryResponse struct {
type TaskPriorityResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Level int `json:"level"`
Color string `json:"color"`
DisplayOrder int `json:"display_order"`
@@ -34,6 +38,7 @@ type TaskPriorityResponse struct {
type TaskFrequencyResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Days *int `json:"days"`
DisplayOrder int `json:"display_order"`
}
@@ -71,35 +76,35 @@ type TaskCompletionResponse struct {
// TaskResponse represents a task in the API response
type TaskResponse struct {
ID uint `json:"id"`
ResidenceID uint `json:"residence_id"`
CreatedByID uint `json:"created_by_id"`
CreatedBy *TaskUserResponse `json:"created_by,omitempty"`
AssignedToID *uint `json:"assigned_to_id"`
AssignedTo *TaskUserResponse `json:"assigned_to,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
CategoryID *uint `json:"category_id"`
Category *TaskCategoryResponse `json:"category,omitempty"`
PriorityID *uint `json:"priority_id"`
Priority *TaskPriorityResponse `json:"priority,omitempty"`
FrequencyID *uint `json:"frequency_id"`
Frequency *TaskFrequencyResponse `json:"frequency,omitempty"`
CustomIntervalDays *int `json:"custom_interval_days"` // For "Custom" frequency, user-specified days
InProgress bool `json:"in_progress"`
DueDate *time.Time `json:"due_date"`
NextDueDate *time.Time `json:"next_due_date"` // For recurring tasks, updated after each completion
EstimatedCost *decimal.Decimal `json:"estimated_cost"`
ActualCost *decimal.Decimal `json:"actual_cost"`
ContractorID *uint `json:"contractor_id"`
IsCancelled bool `json:"is_cancelled"`
IsArchived bool `json:"is_archived"`
ParentTaskID *uint `json:"parent_task_id"`
TemplateID *uint `json:"template_id,omitempty"` // Backlink to the TaskTemplate this task was created from
CompletionCount int `json:"completion_count"`
KanbanColumn string `json:"kanban_column,omitempty"` // Which kanban column this task belongs to
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `json:"id"`
ResidenceID uint `json:"residence_id"`
CreatedByID uint `json:"created_by_id"`
CreatedBy *TaskUserResponse `json:"created_by,omitempty"`
AssignedToID *uint `json:"assigned_to_id"`
AssignedTo *TaskUserResponse `json:"assigned_to,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
CategoryID *uint `json:"category_id"`
Category *TaskCategoryResponse `json:"category,omitempty"`
PriorityID *uint `json:"priority_id"`
Priority *TaskPriorityResponse `json:"priority,omitempty"`
FrequencyID *uint `json:"frequency_id"`
Frequency *TaskFrequencyResponse `json:"frequency,omitempty"`
CustomIntervalDays *int `json:"custom_interval_days"` // For "Custom" frequency, user-specified days
InProgress bool `json:"in_progress"`
DueDate *time.Time `json:"due_date"`
NextDueDate *time.Time `json:"next_due_date"` // For recurring tasks, updated after each completion
EstimatedCost *decimal.Decimal `json:"estimated_cost"`
ActualCost *decimal.Decimal `json:"actual_cost"`
ContractorID *uint `json:"contractor_id"`
IsCancelled bool `json:"is_cancelled"`
IsArchived bool `json:"is_archived"`
ParentTaskID *uint `json:"parent_task_id"`
TemplateID *uint `json:"template_id,omitempty"` // Backlink to the TaskTemplate this task was created from
CompletionCount int `json:"completion_count"`
KanbanColumn string `json:"kanban_column,omitempty"` // Which kanban column this task belongs to
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// BulkCreateTasksResponse is returned by POST /api/tasks/bulk/.
@@ -240,30 +245,30 @@ func NewTaskResponseWithTime(t *models.Task, daysThreshold int, now time.Time) T
// newTaskResponseInternal is the internal implementation for creating task responses
func newTaskResponseInternal(t *models.Task, daysThreshold int, now time.Time) TaskResponse {
resp := TaskResponse{
ID: t.ID,
ResidenceID: t.ResidenceID,
CreatedByID: t.CreatedByID,
Title: t.Title,
Description: t.Description,
CategoryID: t.CategoryID,
PriorityID: t.PriorityID,
ID: t.ID,
ResidenceID: t.ResidenceID,
CreatedByID: t.CreatedByID,
Title: t.Title,
Description: t.Description,
CategoryID: t.CategoryID,
PriorityID: t.PriorityID,
FrequencyID: t.FrequencyID,
CustomIntervalDays: t.CustomIntervalDays,
InProgress: t.InProgress,
AssignedToID: t.AssignedToID,
DueDate: t.DueDate,
NextDueDate: t.NextDueDate,
EstimatedCost: t.EstimatedCost,
ActualCost: t.ActualCost,
ContractorID: t.ContractorID,
IsCancelled: t.IsCancelled,
IsArchived: t.IsArchived,
ParentTaskID: t.ParentTaskID,
TemplateID: t.TaskTemplateID,
CompletionCount: predicates.GetCompletionCount(t),
KanbanColumn: DetermineKanbanColumnWithTime(t, daysThreshold, now),
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
AssignedToID: t.AssignedToID,
DueDate: t.DueDate,
NextDueDate: t.NextDueDate,
EstimatedCost: t.EstimatedCost,
ActualCost: t.ActualCost,
ContractorID: t.ContractorID,
IsCancelled: t.IsCancelled,
IsArchived: t.IsArchived,
ParentTaskID: t.ParentTaskID,
TemplateID: t.TaskTemplateID,
CompletionCount: predicates.GetCompletionCount(t),
KanbanColumn: DetermineKanbanColumnWithTime(t, daysThreshold, now),
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
}
if t.CreatedBy.ID != 0 {
+75
View File
@@ -12,6 +12,7 @@ import (
"github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/validator"
"github.com/treytartt/honeydue-api/internal/worker"
)
// AuthHandler handles user profile and account management endpoints.
@@ -23,6 +24,7 @@ type AuthHandler struct {
cache *services.CacheService
storageService *services.StorageService
auditService *services.AuditService
enqueuer worker.Enqueuer
}
// NewAuthHandler creates a new auth handler.
@@ -44,11 +46,65 @@ func (h *AuthHandler) SetAuditService(auditService *services.AuditService) {
h.auditService = auditService
}
// SetEnqueuer sets the async task enqueuer (used by the GDPR data-export endpoint).
func (h *AuthHandler) SetEnqueuer(enqueuer worker.Enqueuer) {
h.enqueuer = enqueuer
}
// ExportData handles POST /api/auth/export/ — queues a GDPR data-export job that
// emails the user a zip of all their data. Async (202) because gathering,
// zipping, and emailing can take seconds; doing it inline would block the request.
func (h *AuthHandler) ExportData(c echo.Context) error {
noStore(c)
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
if h.enqueuer == nil {
return echo.NewHTTPError(http.StatusServiceUnavailable, "data export is temporarily unavailable")
}
if err := h.enqueuer.EnqueueDataExport(user.ID); err != nil {
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to enqueue data export")
return echo.NewHTTPError(http.StatusInternalServerError, "failed to queue data export")
}
if h.auditService != nil {
h.auditService.LogEvent(c, &user.ID, services.AuditEventDataExport, map[string]interface{}{
"user_id": user.ID,
"email": user.Email,
})
}
return c.JSON(http.StatusAccepted, map[string]string{
"message": "Your data export has been queued. You'll receive an email with your data shortly.",
})
}
// noStore marks a response as non-cacheable.
func noStore(c echo.Context) {
c.Response().Header().Set("Cache-Control", "no-store")
}
// Register handles POST /api/auth/register/ — creates a new password account.
//
// The identity is admin-created in Kratos with an unverified email and no
// auto-sent code (see services.AuthService.Register). The client logs in right
// after to get a session, then completes email verification. Returns 201 with
// no token; 409 if the email is taken; 400 on a weak password.
func (h *AuthHandler) Register(c echo.Context) error {
var req requests.RegisterRequest
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request_body")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
if err := h.authService.Register(c.Request().Context(), &req); err != nil {
return err
}
return c.JSON(http.StatusCreated, map[string]string{
"message": "Account created. Please verify your email.",
})
}
// CurrentUser handles GET /api/auth/me/
func (h *AuthHandler) CurrentUser(c echo.Context) error {
noStore(c)
@@ -63,6 +119,25 @@ func (h *AuthHandler) CurrentUser(c echo.Context) error {
return err
}
// user_profile.verified is a one-time mirror set at provision time
// (see middleware/kratos_auth.go::provision). Kratos remains the source
// of truth for email-verification state — it can flip from false → true
// the instant the user completes the verification flow, and nothing
// updates the local column. Override the response with the live value
// the Kratos auth middleware already stashed in context so /auth/me
// reflects current reality. Also opportunistically sync the DB mirror
// (best-effort, ignore error) so background queries that read the
// column see the same answer.
if verified, ok := c.Get(middleware.AuthVerifiedKey).(bool); ok {
mirrorStale := response.Profile != nil && response.Profile.Verified != verified
if response.Profile != nil {
response.Profile.Verified = verified
}
if verified && mirrorStale {
_ = h.authService.MarkUserVerified(c.Request().Context(), user.ID)
}
}
return c.JSON(http.StatusOK, response)
}
+25 -11
View File
@@ -15,12 +15,15 @@ import (
// SeededDataResponse represents the unified seeded data response
type SeededDataResponse struct {
ResidenceTypes interface{} `json:"residence_types"`
TaskCategories interface{} `json:"task_categories"`
TaskPriorities interface{} `json:"task_priorities"`
TaskFrequencies interface{} `json:"task_frequencies"`
ContractorSpecialties interface{} `json:"contractor_specialties"`
TaskTemplates responses.TaskTemplatesGroupedResponse `json:"task_templates"`
ResidenceTypes interface{} `json:"residence_types"`
TaskCategories interface{} `json:"task_categories"`
TaskPriorities interface{} `json:"task_priorities"`
TaskFrequencies interface{} `json:"task_frequencies"`
ContractorSpecialties interface{} `json:"contractor_specialties"`
TaskTemplates responses.TaskTemplatesGroupedResponse `json:"task_templates"`
HomeProfileOptions map[string][]services.HomeProfileOption `json:"home_profile_options"`
DocumentTypes []services.HomeProfileOption `json:"document_types"`
DocumentCategories []services.HomeProfileOption `json:"document_categories"`
}
// StaticDataHandler handles static/lookup data endpoints
@@ -54,13 +57,18 @@ func NewStaticDataHandler(
func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
ctx := c.Request().Context()
// Lookup display labels and home-profile options are localized for the
// request's language, so the cache + ETag are keyed by locale.
locale := i18n.GetLocale(c)
localizer := i18n.GetLocalizer(c)
// Check If-None-Match header for conditional request
// Strip W/ prefix if present (added by reverse proxy, but we store without it)
clientETag := strings.TrimPrefix(c.Request().Header.Get("If-None-Match"), "W/")
// Try to get cached ETag first (fast path for 304 responses)
if h.cache != nil && clientETag != "" {
cachedETag, err := h.cache.GetSeededDataETag(ctx)
cachedETag, err := h.cache.GetSeededDataETag(ctx, locale)
if err == nil && cachedETag == clientETag {
// Client has the latest data, return 304 Not Modified
return c.NoContent(http.StatusNotModified)
@@ -70,10 +78,10 @@ func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
// Try to get cached seeded data
if h.cache != nil {
var cachedData SeededDataResponse
err := h.cache.GetCachedSeededData(ctx, &cachedData)
err := h.cache.GetCachedSeededData(ctx, locale, &cachedData)
if err == nil {
// Cache hit - get the ETag and return data
etag, etagErr := h.cache.GetSeededDataETag(ctx)
etag, etagErr := h.cache.GetSeededDataETag(ctx, locale)
if etagErr == nil {
c.Response().Header().Set("ETag", etag)
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
@@ -116,6 +124,9 @@ func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
return err
}
// Localize the lookup display_name fields in place for this request's locale.
services.LocalizeLookups(localizer, residenceTypes, taskCategories, taskPriorities, taskFrequencies, contractorSpecialties)
// Build response
seededData := SeededDataResponse{
ResidenceTypes: residenceTypes,
@@ -124,11 +135,14 @@ func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
TaskFrequencies: taskFrequencies,
ContractorSpecialties: contractorSpecialties,
TaskTemplates: taskTemplates,
HomeProfileOptions: services.BuildHomeProfileOptions(localizer),
DocumentTypes: services.BuildDocumentTypes(localizer),
DocumentCategories: services.BuildDocumentCategories(localizer),
}
// Cache the data and get ETag
// Cache the data and get ETag (per-locale)
if h.cache != nil {
etag, cacheErr := h.cache.CacheSeededData(ctx, seededData)
etag, cacheErr := h.cache.CacheSeededData(ctx, locale, seededData)
if cacheErr != nil {
log.Warn().Err(cacheErr).Msg("Failed to cache seeded data")
} else {
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"github.com/labstack/echo/v4"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/i18n"
"github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/services"
)
@@ -41,7 +42,7 @@ func (h *SuggestionHandler) GetSuggestions(c echo.Context) error {
return apperrors.BadRequest("error.invalid_id")
}
resp, err := h.suggestionService.GetSuggestions(uint(residenceID), user.ID)
resp, err := h.suggestionService.GetSuggestions(uint(residenceID), user.ID, i18n.GetLocalizer(c))
if err != nil {
return err
}
+93 -37
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "Google-Anmeldung ist nicht konfiguriert",
"error.google_signin_failed": "Google-Anmeldung fehlgeschlagen",
"error.invalid_google_token": "Ungultiger Google-Identitats-Token",
"error.invalid_task_id": "Ungultige Aufgaben-ID",
"error.invalid_residence_id": "Ungultige Immobilien-ID",
"error.invalid_contractor_id": "Ungultige Dienstleister-ID",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "Ungultige Benutzer-ID",
"error.invalid_notification_id": "Ungultige Benachrichtigungs-ID",
"error.invalid_device_id": "Ungultige Gerate-ID",
"error.task_not_found": "Aufgabe nicht gefunden",
"error.residence_not_found": "Immobilie nicht gefunden",
"error.contractor_not_found": "Dienstleister nicht gefunden",
@@ -43,7 +41,6 @@
"error.user_not_found": "Benutzer nicht gefunden",
"error.share_code_invalid": "Ungultiger Freigabecode",
"error.share_code_expired": "Der Freigabecode ist abgelaufen",
"error.task_access_denied": "Sie haben keinen Zugriff auf diese Aufgabe",
"error.residence_access_denied": "Sie haben keinen Zugriff auf diese Immobilie",
"error.contractor_access_denied": "Sie haben keinen Zugriff auf diesen Dienstleister",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "Der Eigentumer kann nicht entfernt werden",
"error.user_already_member": "Der Benutzer ist bereits Mitglied dieser Immobilie",
"error.properties_limit_reached": "Sie haben die maximale Anzahl an Immobilien fur Ihr Abonnement erreicht",
"error.task_already_cancelled": "Die Aufgabe ist bereits storniert",
"error.task_already_archived": "Die Aufgabe ist bereits archiviert",
"error.failed_to_parse_form": "Formular konnte nicht analysiert werden",
"error.task_id_required": "task_id ist erforderlich",
"error.invalid_task_id_value": "Ungultige task_id",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "Ungultige residence_id",
"error.title_required": "Titel ist erforderlich",
"error.failed_to_upload_file": "Datei konnte nicht hochgeladen werden",
"message.logged_out": "Erfolgreich abgemeldet",
"message.email_verified": "E-Mail erfolgreich verifiziert",
"message.verification_email_sent": "Verifizierungs-E-Mail gesendet",
"message.password_reset_email_sent": "Wenn ein Konto mit dieser E-Mail existiert, wurde ein Zurucksetzungscode gesendet.",
"message.reset_code_verified": "Code erfolgreich verifiziert",
"message.password_reset_success": "Passwort erfolgreich zuruckgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an.",
"message.task_deleted": "Aufgabe erfolgreich geloscht",
"message.task_in_progress": "Aufgabe als in Bearbeitung markiert",
"message.task_cancelled": "Aufgabe storniert",
@@ -79,46 +72,35 @@
"message.task_archived": "Aufgabe archiviert",
"message.task_unarchived": "Aufgabe dearchiviert",
"message.completion_deleted": "Abschluss erfolgreich geloscht",
"message.residence_deleted": "Immobilie erfolgreich geloscht",
"message.user_removed": "Benutzer von der Immobilie entfernt",
"message.tasks_report_generated": "Aufgabenbericht erfolgreich erstellt",
"message.tasks_report_sent": "Aufgabenbericht erstellt und an {{.Email}} gesendet",
"message.tasks_report_email_failed": "Aufgabenbericht erstellt, aber E-Mail konnte nicht gesendet werden",
"message.contractor_deleted": "Dienstleister erfolgreich geloscht",
"message.document_deleted": "Dokument erfolgreich geloscht",
"message.document_activated": "Dokument aktiviert",
"message.document_deactivated": "Dokument deaktiviert",
"message.notification_marked_read": "Benachrichtigung als gelesen markiert",
"message.all_notifications_marked_read": "Alle Benachrichtigungen als gelesen markiert",
"message.device_removed": "Gerät entfernt",
"message.subscription_upgraded": "Abonnement erfolgreich aktualisiert",
"message.subscription_cancelled": "Abonnement gekündigt. Sie behalten die Pro-Vorteile bis zum Ende Ihres Abrechnungszeitraums.",
"message.subscription_restored": "Abonnement erfolgreich wiederhergestellt",
"message.file_deleted": "Datei erfolgreich gelöscht",
"message.static_data_refreshed": "Statische Daten aktualisiert",
"error.notification_not_found": "Benachrichtigung nicht gefunden",
"error.invalid_platform": "Ungültige Plattform",
"error.upgrade_trigger_not_found": "Upgrade-Trigger nicht gefunden",
"error.receipt_data_required": "receipt_data ist für iOS erforderlich",
"error.purchase_token_required": "purchase_token ist für Android erforderlich",
"error.no_file_provided": "Keine Datei bereitgestellt",
"error.failed_to_fetch_residence_types": "Fehler beim Abrufen der Immobilientypen",
"error.failed_to_fetch_task_categories": "Fehler beim Abrufen der Aufgabenkategorien",
"error.failed_to_fetch_task_priorities": "Fehler beim Abrufen der Aufgabenprioritäten",
"error.failed_to_fetch_task_frequencies": "Fehler beim Abrufen der Aufgabenfrequenzen",
"error.failed_to_fetch_task_statuses": "Fehler beim Abrufen der Aufgabenstatus",
"error.failed_to_fetch_contractor_specialties": "Fehler beim Abrufen der Dienstleister-Spezialitäten",
"push.task_due_soon.title": "Aufgabe Bald Fallig",
"push.task_due_soon.body": "{{.TaskTitle}} ist fallig am {{.DueDate}}",
"push.task_overdue.title": "Uberfällige Aufgabe",
@@ -129,63 +111,137 @@
"push.task_assigned.body": "Ihnen wurde {{.TaskTitle}} zugewiesen",
"push.residence_shared.title": "Immobilie Geteilt",
"push.residence_shared.body": "{{.UserName}} hat {{.ResidenceName}} mit Ihnen geteilt",
"email.welcome.subject": "Willkommen bei honeyDue!",
"email.verification.subject": "Bestatigen Sie Ihre E-Mail",
"email.password_reset.subject": "Passwort-Zurucksetzungscode",
"email.tasks_report.subject": "Aufgabenbericht fur {{.ResidenceName}}",
"lookup.residence_type.house": "Haus",
"lookup.residence_type.apartment": "Wohnung",
"lookup.residence_type.condo": "Eigentumswohnung",
"lookup.residence_type.townhouse": "Reihenhaus",
"lookup.residence_type.mobile_home": "Mobilheim",
"lookup.residence_type.other": "Sonstiges",
"lookup.task_category.plumbing": "Sanitär",
"lookup.task_category.electrical": "Elektrik",
"lookup.task_category.hvac": "Heizung/Klimaanlage",
"lookup.task_category.appliances": "Gerate",
"lookup.task_category.exterior": "Aussenbereich",
"lookup.task_category.hvac": "HLK",
"lookup.task_category.appliances": "Haushaltsgeräte",
"lookup.task_category.exterior": "Außenbereich",
"lookup.task_category.interior": "Innenbereich",
"lookup.task_category.landscaping": "Gartenpflege",
"lookup.task_category.safety": "Sicherheit",
"lookup.task_category.cleaning": "Reinigung",
"lookup.task_category.pest_control": "Schadlingsbekampfung",
"lookup.task_category.pest_control": "Schädlingsbekämpfung",
"lookup.task_category.seasonal": "Saisonal",
"lookup.task_category.other": "Sonstiges",
"lookup.task_priority.low": "Niedrig",
"lookup.task_priority.medium": "Mittel",
"lookup.task_priority.high": "Hoch",
"lookup.task_priority.urgent": "Dringend",
"lookup.task_status.pending": "Ausstehend",
"lookup.task_status.in_progress": "In Bearbeitung",
"lookup.task_status.completed": "Abgeschlossen",
"lookup.task_status.cancelled": "Storniert",
"lookup.task_status.archived": "Archiviert",
"lookup.task_frequency.once": "Einmalig",
"lookup.task_frequency.daily": "Taglich",
"lookup.task_frequency.weekly": "Wochentlich",
"lookup.task_frequency.daily": "Täglich",
"lookup.task_frequency.weekly": "Wöchentlich",
"lookup.task_frequency.biweekly": "Alle 2 Wochen",
"lookup.task_frequency.monthly": "Monatlich",
"lookup.task_frequency.quarterly": "Vierteljahrlich",
"lookup.task_frequency.quarterly": "Vierteljährlich",
"lookup.task_frequency.semiannually": "Halbjahrlich",
"lookup.task_frequency.annually": "Jahrlich",
"lookup.task_frequency.annually": "Jährlich",
"lookup.contractor_specialty.plumber": "Klempner",
"lookup.contractor_specialty.electrician": "Elektriker",
"lookup.contractor_specialty.hvac_technician": "HLK-Techniker",
"lookup.contractor_specialty.handyman": "Handwerker",
"lookup.contractor_specialty.landscaper": "Landschaftsgartner",
"lookup.contractor_specialty.landscaper": "Landschaftsgärtner",
"lookup.contractor_specialty.roofer": "Dachdecker",
"lookup.contractor_specialty.painter": "Maler",
"lookup.contractor_specialty.carpenter": "Schreiner",
"lookup.contractor_specialty.pest_control": "Schadlingsbekampfung",
"lookup.contractor_specialty.pest_control": "Schädlingsbekämpfung",
"lookup.contractor_specialty.cleaning": "Reinigung",
"lookup.contractor_specialty.pool_service": "Pool-Service",
"lookup.contractor_specialty.pool_service": "Poolservice",
"lookup.contractor_specialty.general_contractor": "Generalunternehmer",
"lookup.contractor_specialty.other": "Sonstiges"
"lookup.contractor_specialty.other": "Sonstiges",
"suggestion.reason.has_pool": "Ihr Zuhause hat einen Pool",
"suggestion.reason.has_sprinkler_system": "Ihr Zuhause hat eine Bewässerungsanlage",
"suggestion.reason.has_septic": "Ihr Zuhause hat eine Klärgrube",
"suggestion.reason.has_fireplace": "Ihr Zuhause hat einen Kamin",
"suggestion.reason.has_garage": "Ihr Zuhause hat eine Garage",
"suggestion.reason.has_basement": "Ihr Zuhause hat einen Keller",
"suggestion.reason.has_attic": "Ihr Zuhause hat einen Dachboden",
"suggestion.reason.heating_type": "Passt zu Ihrer Heizung",
"suggestion.reason.cooling_type": "Passt zu Ihrer Kühlung",
"suggestion.reason.water_heater_type": "Passt zu Ihrem Warmwasserbereiter",
"suggestion.reason.roof_type": "Passt zu Ihrem Dach",
"suggestion.reason.exterior_type": "Passt zu Ihrer Fassade",
"suggestion.reason.flooring_primary": "Passt zu Ihrem Bodenbelag",
"suggestion.reason.landscaping_type": "Passt zu Ihrer Gartengestaltung",
"suggestion.reason.property_type": "Empfohlen für Ihren Immobilientyp",
"suggestion.reason.climate_region": "Empfohlen für Ihr Klima",
"lookup.residence_type.duplex": "Doppelhaus",
"lookup.residence_type.vacation_home": "Ferienhaus",
"lookup.task_category.general": "Allgemein",
"lookup.task_frequency.bi_weekly": "Zweiwöchentlich",
"lookup.task_frequency.semi_annually": "Halbjährlich",
"lookup.task_frequency.custom": "Benutzerdefiniert",
"lookup.contractor_specialty.appliance_repair": "Gerätereparatur",
"lookup.contractor_specialty.cleaner": "Reinigungskraft",
"lookup.contractor_specialty.locksmith": "Schlosser",
"lookup.home_profile.gas_furnace": "Gasheizung",
"lookup.home_profile.electric_furnace": "Elektroheizung",
"lookup.home_profile.heat_pump": "Wärmepumpe",
"lookup.home_profile.boiler": "Heizkessel",
"lookup.home_profile.radiant": "Strahlungsheizung",
"lookup.home_profile.other": "Sonstiges",
"lookup.home_profile.central_ac": "Zentrale Klimaanlage",
"lookup.home_profile.window_ac": "Fensterklimagerät",
"lookup.home_profile.evaporative": "Verdunstung",
"lookup.home_profile.none": "Keine",
"lookup.home_profile.tank_gas": "Speicher (Gas)",
"lookup.home_profile.tank_electric": "Speicher (Elektro)",
"lookup.home_profile.tankless_gas": "Durchlauf (Gas)",
"lookup.home_profile.tankless_electric": "Durchlauf (Elektro)",
"lookup.home_profile.solar": "Solar",
"lookup.home_profile.asphalt_shingle": "Asphaltschindel",
"lookup.home_profile.metal": "Metall",
"lookup.home_profile.tile": "Ziegel",
"lookup.home_profile.slate": "Schiefer",
"lookup.home_profile.wood_shake": "Holzschindel",
"lookup.home_profile.flat": "Flach",
"lookup.home_profile.brick": "Backstein",
"lookup.home_profile.vinyl_siding": "Vinylverkleidung",
"lookup.home_profile.wood_siding": "Holzverkleidung",
"lookup.home_profile.stucco": "Putz",
"lookup.home_profile.stone": "Stein",
"lookup.home_profile.fiber_cement": "Faserzement",
"lookup.home_profile.hardwood": "Hartholz",
"lookup.home_profile.laminate": "Laminat",
"lookup.home_profile.carpet": "Teppich",
"lookup.home_profile.vinyl": "Vinyl",
"lookup.home_profile.concrete": "Beton",
"lookup.home_profile.lawn": "Rasen",
"lookup.home_profile.desert": "Wüste",
"lookup.home_profile.xeriscape": "Xeriscaping",
"lookup.home_profile.garden": "Garten",
"lookup.home_profile.mixed": "Gemischt",
"lookup.document_type.warranty": "Garantie",
"lookup.document_type.manual": "Benutzerhandbuch",
"lookup.document_type.receipt": "Beleg/Rechnung",
"lookup.document_type.inspection": "Inspektionsbericht",
"lookup.document_type.permit": "Genehmigung",
"lookup.document_type.deed": "Urkunde/Titel",
"lookup.document_type.insurance": "Versicherung",
"lookup.document_type.contract": "Vertrag",
"lookup.document_type.photo": "Foto",
"lookup.document_type.other": "Sonstiges",
"lookup.document_category.appliance": "Haushaltsgerät",
"lookup.document_category.hvac": "HLK",
"lookup.document_category.plumbing": "Sanitär",
"lookup.document_category.electrical": "Elektrik",
"lookup.document_category.roofing": "Dach",
"lookup.document_category.structural": "Struktur",
"lookup.document_category.landscaping": "Gartengestaltung",
"lookup.document_category.general": "Allgemein",
"lookup.document_category.other": "Sonstiges"
}
+82 -26
View File
@@ -28,7 +28,6 @@
"error.google_signin_not_configured": "Google Sign In is not configured",
"error.google_signin_failed": "Google Sign In failed",
"error.invalid_google_token": "Invalid Google identity token",
"error.invalid_task_id": "Invalid task ID",
"error.invalid_residence_id": "Invalid residence ID",
"error.invalid_contractor_id": "Invalid contractor ID",
@@ -37,7 +36,6 @@
"error.invalid_user_id": "Invalid user ID",
"error.invalid_notification_id": "Invalid notification ID",
"error.invalid_device_id": "Invalid device ID",
"error.task_not_found": "Task not found",
"error.residence_not_found": "Residence not found",
"error.contractor_not_found": "Contractor not found",
@@ -46,7 +44,6 @@
"error.user_not_found": "User not found",
"error.share_code_invalid": "Invalid share code",
"error.share_code_expired": "Share code has expired",
"error.task_access_denied": "You don't have access to this task",
"error.residence_access_denied": "You don't have access to this property",
"error.contractor_access_denied": "You don't have access to this contractor",
@@ -55,10 +52,8 @@
"error.cannot_remove_owner": "Cannot remove the property owner",
"error.user_already_member": "User is already a member of this property",
"error.properties_limit_reached": "You have reached the maximum number of properties for your subscription",
"error.task_already_cancelled": "Task is already cancelled",
"error.task_already_archived": "Task is already archived",
"error.failed_to_parse_form": "Failed to parse multipart form",
"error.task_id_required": "task_id is required",
"error.invalid_task_id_value": "Invalid task_id",
@@ -67,14 +62,12 @@
"error.invalid_residence_id_value": "Invalid residence_id",
"error.title_required": "title is required",
"error.failed_to_upload_file": "Failed to upload file",
"message.logged_out": "Logged out successfully",
"message.email_verified": "Email verified successfully",
"message.verification_email_sent": "Verification email sent",
"message.password_reset_email_sent": "If an account with that email exists, a password reset code has been sent.",
"message.reset_code_verified": "Code verified successfully",
"message.password_reset_success": "Password reset successfully. Please log in with your new password.",
"message.task_deleted": "Task deleted successfully",
"message.task_in_progress": "Task marked as in progress",
"message.task_cancelled": "Task cancelled",
@@ -82,44 +75,34 @@
"message.task_archived": "Task archived",
"message.task_unarchived": "Task unarchived",
"message.completion_deleted": "Completion deleted successfully",
"message.residence_deleted": "Residence deleted successfully",
"message.user_removed": "User removed from residence",
"message.tasks_report_generated": "Tasks report generated successfully",
"message.tasks_report_sent": "Tasks report generated and sent to {{.Email}}",
"message.tasks_report_email_failed": "Tasks report generated but email could not be sent",
"message.contractor_deleted": "Contractor deleted successfully",
"message.document_deleted": "Document deleted successfully",
"message.document_activated": "Document activated",
"message.document_deactivated": "Document deactivated",
"message.notification_marked_read": "Notification marked as read",
"message.all_notifications_marked_read": "All notifications marked as read",
"message.device_removed": "Device removed",
"message.subscription_upgraded": "Subscription upgraded successfully",
"message.subscription_cancelled": "Subscription cancelled. You will retain Pro benefits until the end of your billing period.",
"message.subscription_restored": "Subscription restored successfully",
"message.file_deleted": "File deleted successfully",
"message.static_data_refreshed": "Static data refreshed",
"error.notification_not_found": "Notification not found",
"error.invalid_platform": "Invalid platform",
"error.upgrade_trigger_not_found": "Upgrade trigger not found",
"error.receipt_data_required": "receipt_data is required for iOS",
"error.purchase_token_required": "purchase_token is required for Android",
"error.no_file_provided": "No file provided",
"error.url_required": "File URL is required",
"error.file_access_denied": "You don't have access to this file",
"error.days_out_of_range": "Days parameter must be between 1 and 3650",
"error.platform_required": "Platform is required (ios or android)",
"error.registration_id_required": "Registration ID is required",
"error.failed_to_fetch_residence_types": "Failed to fetch residence types",
"error.failed_to_fetch_task_categories": "Failed to fetch task categories",
"error.failed_to_fetch_task_priorities": "Failed to fetch task priorities",
@@ -129,7 +112,6 @@
"error.failed_to_fetch_templates": "Failed to fetch task templates",
"error.failed_to_search_templates": "Failed to search task templates",
"error.template_not_found": "Task template not found",
"push.task_due_soon.title": "Task Due Soon",
"push.task_due_soon.body": "{{.TaskTitle}} is due {{.DueDate}}",
"push.task_overdue.title": "Overdue Task",
@@ -140,19 +122,16 @@
"push.task_assigned.body": "You have been assigned to {{.TaskTitle}}",
"push.residence_shared.title": "Property Shared",
"push.residence_shared.body": "{{.UserName}} shared {{.ResidenceName}} with you",
"email.welcome.subject": "Welcome to honeyDue!",
"email.verification.subject": "Verify Your Email",
"email.password_reset.subject": "Password Reset Code",
"email.tasks_report.subject": "Tasks Report for {{.ResidenceName}}",
"lookup.residence_type.house": "House",
"lookup.residence_type.apartment": "Apartment",
"lookup.residence_type.condo": "Condo",
"lookup.residence_type.townhouse": "Townhouse",
"lookup.residence_type.mobile_home": "Mobile Home",
"lookup.residence_type.other": "Other",
"lookup.task_category.plumbing": "Plumbing",
"lookup.task_category.electrical": "Electrical",
"lookup.task_category.hvac": "HVAC",
@@ -165,18 +144,15 @@
"lookup.task_category.pest_control": "Pest Control",
"lookup.task_category.seasonal": "Seasonal",
"lookup.task_category.other": "Other",
"lookup.task_priority.low": "Low",
"lookup.task_priority.medium": "Medium",
"lookup.task_priority.high": "High",
"lookup.task_priority.urgent": "Urgent",
"lookup.task_status.pending": "Pending",
"lookup.task_status.in_progress": "In Progress",
"lookup.task_status.completed": "Completed",
"lookup.task_status.cancelled": "Cancelled",
"lookup.task_status.archived": "Archived",
"lookup.task_frequency.once": "Once",
"lookup.task_frequency.daily": "Daily",
"lookup.task_frequency.weekly": "Weekly",
@@ -185,7 +161,6 @@
"lookup.task_frequency.quarterly": "Quarterly",
"lookup.task_frequency.semiannually": "Every 6 Months",
"lookup.task_frequency.annually": "Annually",
"lookup.contractor_specialty.plumber": "Plumber",
"lookup.contractor_specialty.electrician": "Electrician",
"lookup.contractor_specialty.hvac_technician": "HVAC Technician",
@@ -198,5 +173,86 @@
"lookup.contractor_specialty.cleaning": "Cleaning",
"lookup.contractor_specialty.pool_service": "Pool Service",
"lookup.contractor_specialty.general_contractor": "General Contractor",
"lookup.contractor_specialty.other": "Other"
"lookup.contractor_specialty.other": "Other",
"suggestion.reason.has_pool": "Your home has a pool",
"suggestion.reason.has_sprinkler_system": "Your home has a sprinkler system",
"suggestion.reason.has_septic": "Your home has a septic system",
"suggestion.reason.has_fireplace": "Your home has a fireplace",
"suggestion.reason.has_garage": "Your home has a garage",
"suggestion.reason.has_basement": "Your home has a basement",
"suggestion.reason.has_attic": "Your home has an attic",
"suggestion.reason.heating_type": "Matches your heating system",
"suggestion.reason.cooling_type": "Matches your cooling system",
"suggestion.reason.water_heater_type": "Matches your water heater",
"suggestion.reason.roof_type": "Matches your roof",
"suggestion.reason.exterior_type": "Matches your exterior",
"suggestion.reason.flooring_primary": "Matches your flooring",
"suggestion.reason.landscaping_type": "Matches your landscaping",
"suggestion.reason.property_type": "Recommended for your property type",
"suggestion.reason.climate_region": "Recommended for your climate",
"lookup.residence_type.duplex": "Duplex",
"lookup.residence_type.vacation_home": "Vacation Home",
"lookup.task_category.general": "General",
"lookup.task_frequency.bi_weekly": "Bi-Weekly",
"lookup.task_frequency.semi_annually": "Semi-Annually",
"lookup.task_frequency.custom": "Custom",
"lookup.contractor_specialty.appliance_repair": "Appliance Repair",
"lookup.contractor_specialty.cleaner": "Cleaner",
"lookup.contractor_specialty.locksmith": "Locksmith",
"lookup.home_profile.gas_furnace": "Gas Furnace",
"lookup.home_profile.electric_furnace": "Electric Furnace",
"lookup.home_profile.heat_pump": "Heat Pump",
"lookup.home_profile.boiler": "Boiler",
"lookup.home_profile.radiant": "Radiant",
"lookup.home_profile.other": "Other",
"lookup.home_profile.central_ac": "Central AC",
"lookup.home_profile.window_ac": "Window AC",
"lookup.home_profile.evaporative": "Evaporative",
"lookup.home_profile.none": "None",
"lookup.home_profile.tank_gas": "Tank (Gas)",
"lookup.home_profile.tank_electric": "Tank (Electric)",
"lookup.home_profile.tankless_gas": "Tankless (Gas)",
"lookup.home_profile.tankless_electric": "Tankless (Electric)",
"lookup.home_profile.solar": "Solar",
"lookup.home_profile.asphalt_shingle": "Asphalt Shingle",
"lookup.home_profile.metal": "Metal",
"lookup.home_profile.tile": "Tile",
"lookup.home_profile.slate": "Slate",
"lookup.home_profile.wood_shake": "Wood Shake",
"lookup.home_profile.flat": "Flat",
"lookup.home_profile.brick": "Brick",
"lookup.home_profile.vinyl_siding": "Vinyl Siding",
"lookup.home_profile.wood_siding": "Wood Siding",
"lookup.home_profile.stucco": "Stucco",
"lookup.home_profile.stone": "Stone",
"lookup.home_profile.fiber_cement": "Fiber Cement",
"lookup.home_profile.hardwood": "Hardwood",
"lookup.home_profile.laminate": "Laminate",
"lookup.home_profile.carpet": "Carpet",
"lookup.home_profile.vinyl": "Vinyl",
"lookup.home_profile.concrete": "Concrete",
"lookup.home_profile.lawn": "Lawn",
"lookup.home_profile.desert": "Desert",
"lookup.home_profile.xeriscape": "Xeriscape",
"lookup.home_profile.garden": "Garden",
"lookup.home_profile.mixed": "Mixed",
"lookup.document_type.warranty": "Warranty",
"lookup.document_type.manual": "User Manual",
"lookup.document_type.receipt": "Receipt/Invoice",
"lookup.document_type.inspection": "Inspection Report",
"lookup.document_type.permit": "Permit",
"lookup.document_type.deed": "Deed/Title",
"lookup.document_type.insurance": "Insurance",
"lookup.document_type.contract": "Contract",
"lookup.document_type.photo": "Photo",
"lookup.document_type.other": "Other",
"lookup.document_category.appliance": "Appliance",
"lookup.document_category.hvac": "HVAC",
"lookup.document_category.plumbing": "Plumbing",
"lookup.document_category.electrical": "Electrical",
"lookup.document_category.roofing": "Roofing",
"lookup.document_category.structural": "Structural",
"lookup.document_category.landscaping": "Landscaping",
"lookup.document_category.general": "General",
"lookup.document_category.other": "Other"
}
+95 -39
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "El inicio de sesion con Google no esta configurado",
"error.google_signin_failed": "Error en el inicio de sesion con Google",
"error.invalid_google_token": "Token de identidad de Google no valido",
"error.invalid_task_id": "ID de tarea no valido",
"error.invalid_residence_id": "ID de propiedad no valido",
"error.invalid_contractor_id": "ID de contratista no valido",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "ID de usuario no valido",
"error.invalid_notification_id": "ID de notificacion no valido",
"error.invalid_device_id": "ID de dispositivo no valido",
"error.task_not_found": "Tarea no encontrada",
"error.residence_not_found": "Propiedad no encontrada",
"error.contractor_not_found": "Contratista no encontrado",
@@ -43,7 +41,6 @@
"error.user_not_found": "Usuario no encontrado",
"error.share_code_invalid": "Codigo de compartir no valido",
"error.share_code_expired": "El codigo de compartir ha expirado",
"error.task_access_denied": "No tienes acceso a esta tarea",
"error.residence_access_denied": "No tienes acceso a esta propiedad",
"error.contractor_access_denied": "No tienes acceso a este contratista",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "No se puede eliminar al propietario de la propiedad",
"error.user_already_member": "El usuario ya es miembro de esta propiedad",
"error.properties_limit_reached": "Has alcanzado el numero maximo de propiedades para tu suscripcion",
"error.task_already_cancelled": "La tarea ya esta cancelada",
"error.task_already_archived": "La tarea ya esta archivada",
"error.failed_to_parse_form": "Error al analizar el formulario",
"error.task_id_required": "Se requiere task_id",
"error.invalid_task_id_value": "task_id no valido",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "residence_id no valido",
"error.title_required": "Se requiere el titulo",
"error.failed_to_upload_file": "Error al subir el archivo",
"message.logged_out": "Sesion cerrada correctamente",
"message.email_verified": "Correo electronico verificado correctamente",
"message.verification_email_sent": "Correo de verificacion enviado",
"message.password_reset_email_sent": "Si existe una cuenta con ese correo electronico, se ha enviado un codigo de restablecimiento de contrasena.",
"message.reset_code_verified": "Codigo verificado correctamente",
"message.password_reset_success": "Contrasena restablecida correctamente. Por favor, inicia sesion con tu nueva contrasena.",
"message.task_deleted": "Tarea eliminada correctamente",
"message.task_in_progress": "Tarea marcada como en progreso",
"message.task_cancelled": "Tarea cancelada",
@@ -79,46 +72,35 @@
"message.task_archived": "Tarea archivada",
"message.task_unarchived": "Tarea desarchivada",
"message.completion_deleted": "Finalizacion eliminada correctamente",
"message.residence_deleted": "Propiedad eliminada correctamente",
"message.user_removed": "Usuario eliminado de la propiedad",
"message.tasks_report_generated": "Informe de tareas generado correctamente",
"message.tasks_report_sent": "Informe de tareas generado y enviado a {{.Email}}",
"message.tasks_report_email_failed": "Informe de tareas generado pero no se pudo enviar el correo",
"message.contractor_deleted": "Contratista eliminado correctamente",
"message.document_deleted": "Documento eliminado correctamente",
"message.document_activated": "Documento activado",
"message.document_deactivated": "Documento desactivado",
"message.notification_marked_read": "Notificación marcada como leída",
"message.all_notifications_marked_read": "Todas las notificaciones marcadas como leídas",
"message.device_removed": "Dispositivo eliminado",
"message.subscription_upgraded": "Suscripción actualizada correctamente",
"message.subscription_cancelled": "Suscripción cancelada. Mantendrás los beneficios Pro hasta el final de tu período de facturación.",
"message.subscription_restored": "Suscripción restaurada correctamente",
"message.file_deleted": "Archivo eliminado correctamente",
"message.static_data_refreshed": "Datos estáticos actualizados",
"error.notification_not_found": "Notificación no encontrada",
"error.invalid_platform": "Plataforma no válida",
"error.upgrade_trigger_not_found": "Trigger de actualización no encontrado",
"error.receipt_data_required": "Se requiere receipt_data para iOS",
"error.purchase_token_required": "Se requiere purchase_token para Android",
"error.no_file_provided": "No se proporcionó ningún archivo",
"error.failed_to_fetch_residence_types": "Error al obtener los tipos de propiedad",
"error.failed_to_fetch_task_categories": "Error al obtener las categorías de tareas",
"error.failed_to_fetch_task_priorities": "Error al obtener las prioridades de tareas",
"error.failed_to_fetch_task_frequencies": "Error al obtener las frecuencias de tareas",
"error.failed_to_fetch_task_statuses": "Error al obtener los estados de tareas",
"error.failed_to_fetch_contractor_specialties": "Error al obtener las especialidades de contratistas",
"push.task_due_soon.title": "Tarea Proxima a Vencer",
"push.task_due_soon.body": "{{.TaskTitle}} vence {{.DueDate}}",
"push.task_overdue.title": "Tarea Vencida",
@@ -129,44 +111,38 @@
"push.task_assigned.body": "Se te ha asignado {{.TaskTitle}}",
"push.residence_shared.title": "Propiedad Compartida",
"push.residence_shared.body": "{{.UserName}} compartio {{.ResidenceName}} contigo",
"email.welcome.subject": "Bienvenido a honeyDue!",
"email.verification.subject": "Verifica Tu Correo Electronico",
"email.password_reset.subject": "Codigo de Restablecimiento de Contrasena",
"email.tasks_report.subject": "Informe de Tareas para {{.ResidenceName}}",
"lookup.residence_type.house": "Casa",
"lookup.residence_type.apartment": "Apartamento",
"lookup.residence_type.condo": "Condominio",
"lookup.residence_type.townhouse": "Casa Adosada",
"lookup.residence_type.mobile_home": "Casa Movil",
"lookup.residence_type.townhouse": "Casa adosada",
"lookup.residence_type.mobile_home": "Casa vil",
"lookup.residence_type.other": "Otro",
"lookup.task_category.plumbing": "Plomeria",
"lookup.task_category.electrical": "Electricidad",
"lookup.task_category.hvac": "Climatizacion",
"lookup.task_category.appliances": "Electrodomesticos",
"lookup.task_category.plumbing": "Fontanería",
"lookup.task_category.electrical": "Eléctrico",
"lookup.task_category.hvac": "Climatización",
"lookup.task_category.appliances": "Electrodomésticos",
"lookup.task_category.exterior": "Exterior",
"lookup.task_category.interior": "Interior",
"lookup.task_category.landscaping": "Jardineria",
"lookup.task_category.safety": "Seguridad",
"lookup.task_category.cleaning": "Limpieza",
"lookup.task_category.pest_control": "Control de Plagas",
"lookup.task_category.pest_control": "Control de plagas",
"lookup.task_category.seasonal": "Estacional",
"lookup.task_category.other": "Otro",
"lookup.task_priority.low": "Baja",
"lookup.task_priority.medium": "Media",
"lookup.task_priority.high": "Alta",
"lookup.task_priority.urgent": "Urgente",
"lookup.task_status.pending": "Pendiente",
"lookup.task_status.in_progress": "En Progreso",
"lookup.task_status.completed": "Completada",
"lookup.task_status.cancelled": "Cancelada",
"lookup.task_status.archived": "Archivada",
"lookup.task_frequency.once": "Una Vez",
"lookup.task_frequency.once": "Una vez",
"lookup.task_frequency.daily": "Diario",
"lookup.task_frequency.weekly": "Semanal",
"lookup.task_frequency.biweekly": "Cada 2 Semanas",
@@ -174,18 +150,98 @@
"lookup.task_frequency.quarterly": "Trimestral",
"lookup.task_frequency.semiannually": "Cada 6 Meses",
"lookup.task_frequency.annually": "Anual",
"lookup.contractor_specialty.plumber": "Plomero",
"lookup.contractor_specialty.plumber": "Fontanero",
"lookup.contractor_specialty.electrician": "Electricista",
"lookup.contractor_specialty.hvac_technician": "Tecnico de Climatizacion",
"lookup.contractor_specialty.hvac_technician": "Técnico de climatización",
"lookup.contractor_specialty.handyman": "Manitas",
"lookup.contractor_specialty.landscaper": "Jardinero",
"lookup.contractor_specialty.roofer": "Techador",
"lookup.contractor_specialty.painter": "Pintor",
"lookup.contractor_specialty.carpenter": "Carpintero",
"lookup.contractor_specialty.pest_control": "Control de Plagas",
"lookup.contractor_specialty.pest_control": "Control de plagas",
"lookup.contractor_specialty.cleaning": "Limpieza",
"lookup.contractor_specialty.pool_service": "Servicio de Piscina",
"lookup.contractor_specialty.general_contractor": "Contratista General",
"lookup.contractor_specialty.other": "Otro"
"lookup.contractor_specialty.pool_service": "Servicio de piscina",
"lookup.contractor_specialty.general_contractor": "Contratista general",
"lookup.contractor_specialty.other": "Otro",
"suggestion.reason.has_pool": "Tu casa tiene piscina",
"suggestion.reason.has_sprinkler_system": "Tu casa tiene sistema de riego",
"suggestion.reason.has_septic": "Tu casa tiene fosa séptica",
"suggestion.reason.has_fireplace": "Tu casa tiene chimenea",
"suggestion.reason.has_garage": "Tu casa tiene garaje",
"suggestion.reason.has_basement": "Tu casa tiene sótano",
"suggestion.reason.has_attic": "Tu casa tiene ático",
"suggestion.reason.heating_type": "Coincide con tu sistema de calefacción",
"suggestion.reason.cooling_type": "Coincide con tu sistema de refrigeración",
"suggestion.reason.water_heater_type": "Coincide con tu calentador de agua",
"suggestion.reason.roof_type": "Coincide con tu tejado",
"suggestion.reason.exterior_type": "Coincide con tu exterior",
"suggestion.reason.flooring_primary": "Coincide con tu suelo",
"suggestion.reason.landscaping_type": "Coincide con tu jardín",
"suggestion.reason.property_type": "Recomendado para tu tipo de propiedad",
"suggestion.reason.climate_region": "Recomendado para tu clima",
"lookup.residence_type.duplex": "Dúplex",
"lookup.residence_type.vacation_home": "Casa de vacaciones",
"lookup.task_category.general": "General",
"lookup.task_frequency.bi_weekly": "Quincenal",
"lookup.task_frequency.semi_annually": "Semestral",
"lookup.task_frequency.custom": "Personalizado",
"lookup.contractor_specialty.appliance_repair": "Reparación de electrodomésticos",
"lookup.contractor_specialty.cleaner": "Limpiador",
"lookup.contractor_specialty.locksmith": "Cerrajero",
"lookup.home_profile.gas_furnace": "Calefactor de gas",
"lookup.home_profile.electric_furnace": "Calefactor eléctrico",
"lookup.home_profile.heat_pump": "Bomba de calor",
"lookup.home_profile.boiler": "Caldera",
"lookup.home_profile.radiant": "Radiante",
"lookup.home_profile.other": "Otro",
"lookup.home_profile.central_ac": "AC central",
"lookup.home_profile.window_ac": "AC de ventana",
"lookup.home_profile.evaporative": "Evaporativo",
"lookup.home_profile.none": "Ninguno",
"lookup.home_profile.tank_gas": "Tanque (gas)",
"lookup.home_profile.tank_electric": "Tanque (eléctrico)",
"lookup.home_profile.tankless_gas": "Sin tanque (gas)",
"lookup.home_profile.tankless_electric": "Sin tanque (eléctrico)",
"lookup.home_profile.solar": "Solar",
"lookup.home_profile.asphalt_shingle": "Teja asfáltica",
"lookup.home_profile.metal": "Metal",
"lookup.home_profile.tile": "Teja",
"lookup.home_profile.slate": "Pizarra",
"lookup.home_profile.wood_shake": "Tablilla de madera",
"lookup.home_profile.flat": "Plano",
"lookup.home_profile.brick": "Ladrillo",
"lookup.home_profile.vinyl_siding": "Revestimiento de vinilo",
"lookup.home_profile.wood_siding": "Revestimiento de madera",
"lookup.home_profile.stucco": "Estuco",
"lookup.home_profile.stone": "Piedra",
"lookup.home_profile.fiber_cement": "Fibrocemento",
"lookup.home_profile.hardwood": "Madera dura",
"lookup.home_profile.laminate": "Laminado",
"lookup.home_profile.carpet": "Alfombra",
"lookup.home_profile.vinyl": "Vinilo",
"lookup.home_profile.concrete": "Hormigón",
"lookup.home_profile.lawn": "Césped",
"lookup.home_profile.desert": "Desierto",
"lookup.home_profile.xeriscape": "Xerojardinería",
"lookup.home_profile.garden": "Jardín",
"lookup.home_profile.mixed": "Mixto",
"lookup.document_type.warranty": "Garantía",
"lookup.document_type.manual": "Manual de usuario",
"lookup.document_type.receipt": "Recibo/Factura",
"lookup.document_type.inspection": "Informe de inspección",
"lookup.document_type.permit": "Permiso",
"lookup.document_type.deed": "Escritura/Título",
"lookup.document_type.insurance": "Seguro",
"lookup.document_type.contract": "Contrato",
"lookup.document_type.photo": "Foto",
"lookup.document_type.other": "Otro",
"lookup.document_category.appliance": "Electrodoméstico",
"lookup.document_category.hvac": "Climatización",
"lookup.document_category.plumbing": "Fontanería",
"lookup.document_category.electrical": "Eléctrico",
"lookup.document_category.roofing": "Tejado",
"lookup.document_category.structural": "Estructural",
"lookup.document_category.landscaping": "Jardinería",
"lookup.document_category.general": "General",
"lookup.document_category.other": "Otro"
}
+98 -42
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "La connexion Google n'est pas configuree",
"error.google_signin_failed": "Echec de la connexion Google",
"error.invalid_google_token": "Jeton d'identite Google non valide",
"error.invalid_task_id": "ID de tache non valide",
"error.invalid_residence_id": "ID de propriete non valide",
"error.invalid_contractor_id": "ID de prestataire non valide",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "ID d'utilisateur non valide",
"error.invalid_notification_id": "ID de notification non valide",
"error.invalid_device_id": "ID d'appareil non valide",
"error.task_not_found": "Tache non trouvee",
"error.residence_not_found": "Propriete non trouvee",
"error.contractor_not_found": "Prestataire non trouve",
@@ -43,7 +41,6 @@
"error.user_not_found": "Utilisateur non trouve",
"error.share_code_invalid": "Code de partage non valide",
"error.share_code_expired": "Le code de partage a expire",
"error.task_access_denied": "Vous n'avez pas acces a cette tache",
"error.residence_access_denied": "Vous n'avez pas acces a cette propriete",
"error.contractor_access_denied": "Vous n'avez pas acces a ce prestataire",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "Impossible de retirer le proprietaire",
"error.user_already_member": "L'utilisateur est deja membre de cette propriete",
"error.properties_limit_reached": "Vous avez atteint le nombre maximum de proprietes pour votre abonnement",
"error.task_already_cancelled": "La tache est deja annulee",
"error.task_already_archived": "La tache est deja archivee",
"error.failed_to_parse_form": "Echec de l'analyse du formulaire",
"error.task_id_required": "task_id est requis",
"error.invalid_task_id_value": "task_id non valide",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "residence_id non valide",
"error.title_required": "Le titre est requis",
"error.failed_to_upload_file": "Echec du telechargement du fichier",
"message.logged_out": "Deconnexion reussie",
"message.email_verified": "Email verifie avec succes",
"message.verification_email_sent": "Email de verification envoye",
"message.password_reset_email_sent": "Si un compte existe avec cet email, un code de reinitialisation a ete envoye.",
"message.reset_code_verified": "Code verifie avec succes",
"message.password_reset_success": "Mot de passe reinitialise avec succes. Veuillez vous connecter avec votre nouveau mot de passe.",
"message.task_deleted": "Tache supprimee avec succes",
"message.task_in_progress": "Tache marquee comme en cours",
"message.task_cancelled": "Tache annulee",
@@ -79,46 +72,35 @@
"message.task_archived": "Tache archivee",
"message.task_unarchived": "Tache desarchivee",
"message.completion_deleted": "Completion supprimee avec succes",
"message.residence_deleted": "Propriete supprimee avec succes",
"message.user_removed": "Utilisateur retire de la propriete",
"message.tasks_report_generated": "Rapport de taches genere avec succes",
"message.tasks_report_sent": "Rapport de taches genere et envoye a {{.Email}}",
"message.tasks_report_email_failed": "Rapport de taches genere mais l'email n'a pas pu etre envoye",
"message.contractor_deleted": "Prestataire supprime avec succes",
"message.document_deleted": "Document supprime avec succes",
"message.document_activated": "Document active",
"message.document_deactivated": "Document desactive",
"message.notification_marked_read": "Notification marquée comme lue",
"message.all_notifications_marked_read": "Toutes les notifications marquées comme lues",
"message.device_removed": "Appareil supprimé",
"message.subscription_upgraded": "Abonnement mis à niveau avec succès",
"message.subscription_cancelled": "Abonnement annulé. Vous conserverez les avantages Pro jusqu'à la fin de votre période de facturation.",
"message.subscription_restored": "Abonnement restauré avec succès",
"message.file_deleted": "Fichier supprimé avec succès",
"message.static_data_refreshed": "Données statiques actualisées",
"error.notification_not_found": "Notification non trouvée",
"error.invalid_platform": "Plateforme non valide",
"error.upgrade_trigger_not_found": "Déclencheur de mise à niveau non trouvé",
"error.receipt_data_required": "receipt_data est requis pour iOS",
"error.purchase_token_required": "purchase_token est requis pour Android",
"error.no_file_provided": "Aucun fichier fourni",
"error.failed_to_fetch_residence_types": "Échec de la récupération des types de propriété",
"error.failed_to_fetch_task_categories": "Échec de la récupération des catégories de tâches",
"error.failed_to_fetch_task_priorities": "Échec de la récupération des priorités de tâches",
"error.failed_to_fetch_task_frequencies": "Échec de la récupération des fréquences de tâches",
"error.failed_to_fetch_task_statuses": "Échec de la récupération des statuts de tâches",
"error.failed_to_fetch_contractor_specialties": "Échec de la récupération des spécialités des prestataires",
"push.task_due_soon.title": "Tache Bientot Due",
"push.task_due_soon.body": "{{.TaskTitle}} est due le {{.DueDate}}",
"push.task_overdue.title": "Tache en Retard",
@@ -129,44 +111,38 @@
"push.task_assigned.body": "{{.TaskTitle}} vous a ete assignee",
"push.residence_shared.title": "Propriete Partagee",
"push.residence_shared.body": "{{.UserName}} a partage {{.ResidenceName}} avec vous",
"email.welcome.subject": "Bienvenue sur honeyDue !",
"email.verification.subject": "Verifiez Votre Email",
"email.password_reset.subject": "Code de Reinitialisation de Mot de Passe",
"email.tasks_report.subject": "Rapport de Taches pour {{.ResidenceName}}",
"lookup.residence_type.house": "Maison",
"lookup.residence_type.apartment": "Appartement",
"lookup.residence_type.condo": "Copropriete",
"lookup.residence_type.townhouse": "Maison de Ville",
"lookup.residence_type.mobile_home": "Mobil-home",
"lookup.residence_type.condo": "Copropriété",
"lookup.residence_type.townhouse": "Maison de ville",
"lookup.residence_type.mobile_home": "Maison mobile",
"lookup.residence_type.other": "Autre",
"lookup.task_category.plumbing": "Plomberie",
"lookup.task_category.electrical": "Electricite",
"lookup.task_category.hvac": "Climatisation",
"lookup.task_category.appliances": "Electromenager",
"lookup.task_category.exterior": "Exterieur",
"lookup.task_category.interior": "Interieur",
"lookup.task_category.electrical": "Électricité",
"lookup.task_category.hvac": "CVC",
"lookup.task_category.appliances": "Électroménager",
"lookup.task_category.exterior": "Extérieur",
"lookup.task_category.interior": "Intérieur",
"lookup.task_category.landscaping": "Jardinage",
"lookup.task_category.safety": "Securite",
"lookup.task_category.safety": "Sécurité",
"lookup.task_category.cleaning": "Nettoyage",
"lookup.task_category.pest_control": "Lutte Antiparasitaire",
"lookup.task_category.pest_control": "Lutte antiparasitaire",
"lookup.task_category.seasonal": "Saisonnier",
"lookup.task_category.other": "Autre",
"lookup.task_priority.low": "Basse",
"lookup.task_priority.medium": "Moyenne",
"lookup.task_priority.high": "Haute",
"lookup.task_priority.urgent": "Urgente",
"lookup.task_status.pending": "En Attente",
"lookup.task_status.in_progress": "En Cours",
"lookup.task_status.completed": "Terminee",
"lookup.task_status.cancelled": "Annulee",
"lookup.task_status.archived": "Archivee",
"lookup.task_frequency.once": "Une Fois",
"lookup.task_frequency.once": "Une fois",
"lookup.task_frequency.daily": "Quotidien",
"lookup.task_frequency.weekly": "Hebdomadaire",
"lookup.task_frequency.biweekly": "Toutes les 2 Semaines",
@@ -174,18 +150,98 @@
"lookup.task_frequency.quarterly": "Trimestriel",
"lookup.task_frequency.semiannually": "Tous les 6 Mois",
"lookup.task_frequency.annually": "Annuel",
"lookup.contractor_specialty.plumber": "Plombier",
"lookup.contractor_specialty.electrician": "Electricien",
"lookup.contractor_specialty.electrician": "Électricien",
"lookup.contractor_specialty.hvac_technician": "Technicien CVC",
"lookup.contractor_specialty.handyman": "Bricoleur",
"lookup.contractor_specialty.landscaper": "Paysagiste",
"lookup.contractor_specialty.roofer": "Couvreur",
"lookup.contractor_specialty.painter": "Peintre",
"lookup.contractor_specialty.carpenter": "Menuisier",
"lookup.contractor_specialty.pest_control": "Desinsectisation",
"lookup.contractor_specialty.carpenter": "Charpentier",
"lookup.contractor_specialty.pest_control": "Lutte antiparasitaire",
"lookup.contractor_specialty.cleaning": "Nettoyage",
"lookup.contractor_specialty.pool_service": "Service Piscine",
"lookup.contractor_specialty.general_contractor": "Entrepreneur General",
"lookup.contractor_specialty.other": "Autre"
"lookup.contractor_specialty.pool_service": "Service de piscine",
"lookup.contractor_specialty.general_contractor": "Entrepreneur général",
"lookup.contractor_specialty.other": "Autre",
"suggestion.reason.has_pool": "Votre logement a une piscine",
"suggestion.reason.has_sprinkler_system": "Votre logement a un système d'arrosage",
"suggestion.reason.has_septic": "Votre logement a une fosse septique",
"suggestion.reason.has_fireplace": "Votre logement a une cheminée",
"suggestion.reason.has_garage": "Votre logement a un garage",
"suggestion.reason.has_basement": "Votre logement a un sous-sol",
"suggestion.reason.has_attic": "Votre logement a des combles",
"suggestion.reason.heating_type": "Correspond à votre système de chauffage",
"suggestion.reason.cooling_type": "Correspond à votre système de climatisation",
"suggestion.reason.water_heater_type": "Correspond à votre chauffe-eau",
"suggestion.reason.roof_type": "Correspond à votre toiture",
"suggestion.reason.exterior_type": "Correspond à votre extérieur",
"suggestion.reason.flooring_primary": "Correspond à votre revêtement de sol",
"suggestion.reason.landscaping_type": "Correspond à votre aménagement paysager",
"suggestion.reason.property_type": "Recommandé pour votre type de logement",
"suggestion.reason.climate_region": "Recommandé pour votre climat",
"lookup.residence_type.duplex": "Duplex",
"lookup.residence_type.vacation_home": "Maison de vacances",
"lookup.task_category.general": "Général",
"lookup.task_frequency.bi_weekly": "Bimensuel",
"lookup.task_frequency.semi_annually": "Semestriel",
"lookup.task_frequency.custom": "Personnalisé",
"lookup.contractor_specialty.appliance_repair": "Réparation d'électroménager",
"lookup.contractor_specialty.cleaner": "Agent de nettoyage",
"lookup.contractor_specialty.locksmith": "Serrurier",
"lookup.home_profile.gas_furnace": "Fournaise au gaz",
"lookup.home_profile.electric_furnace": "Fournaise électrique",
"lookup.home_profile.heat_pump": "Pompe à chaleur",
"lookup.home_profile.boiler": "Chaudière",
"lookup.home_profile.radiant": "Rayonnant",
"lookup.home_profile.other": "Autre",
"lookup.home_profile.central_ac": "Climatisation centrale",
"lookup.home_profile.window_ac": "Climatiseur de fenêtre",
"lookup.home_profile.evaporative": "Évaporatif",
"lookup.home_profile.none": "Aucun",
"lookup.home_profile.tank_gas": "Réservoir (gaz)",
"lookup.home_profile.tank_electric": "Réservoir (électrique)",
"lookup.home_profile.tankless_gas": "Sans réservoir (gaz)",
"lookup.home_profile.tankless_electric": "Sans réservoir (électrique)",
"lookup.home_profile.solar": "Solaire",
"lookup.home_profile.asphalt_shingle": "Bardeau d'asphalte",
"lookup.home_profile.metal": "Métal",
"lookup.home_profile.tile": "Tuile",
"lookup.home_profile.slate": "Ardoise",
"lookup.home_profile.wood_shake": "Bardeau de bois",
"lookup.home_profile.flat": "Plat",
"lookup.home_profile.brick": "Brique",
"lookup.home_profile.vinyl_siding": "Revêtement vinyle",
"lookup.home_profile.wood_siding": "Revêtement bois",
"lookup.home_profile.stucco": "Stuc",
"lookup.home_profile.stone": "Pierre",
"lookup.home_profile.fiber_cement": "Fibrociment",
"lookup.home_profile.hardwood": "Bois franc",
"lookup.home_profile.laminate": "Stratifié",
"lookup.home_profile.carpet": "Moquette",
"lookup.home_profile.vinyl": "Vinyle",
"lookup.home_profile.concrete": "Béton",
"lookup.home_profile.lawn": "Pelouse",
"lookup.home_profile.desert": "Désert",
"lookup.home_profile.xeriscape": "Xéropaysagisme",
"lookup.home_profile.garden": "Jardin",
"lookup.home_profile.mixed": "Mixte",
"lookup.document_type.warranty": "Garantie",
"lookup.document_type.manual": "Manuel d'utilisation",
"lookup.document_type.receipt": "Reçu/Facture",
"lookup.document_type.inspection": "Rapport d'inspection",
"lookup.document_type.permit": "Permis",
"lookup.document_type.deed": "Acte/Titre",
"lookup.document_type.insurance": "Assurance",
"lookup.document_type.contract": "Contrat",
"lookup.document_type.photo": "Photo",
"lookup.document_type.other": "Autre",
"lookup.document_category.appliance": "Électroménager",
"lookup.document_category.hvac": "CVC",
"lookup.document_category.plumbing": "Plomberie",
"lookup.document_category.electrical": "Électricité",
"lookup.document_category.roofing": "Toiture",
"lookup.document_category.structural": "Structure",
"lookup.document_category.landscaping": "Aménagement paysager",
"lookup.document_category.general": "Général",
"lookup.document_category.other": "Autre"
}
+92 -36
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "L'accesso con Google non è configurato",
"error.google_signin_failed": "Accesso con Google fallito",
"error.invalid_google_token": "Token di identità Google non valido",
"error.invalid_task_id": "ID attività non valido",
"error.invalid_residence_id": "ID immobile non valido",
"error.invalid_contractor_id": "ID fornitore non valido",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "ID utente non valido",
"error.invalid_notification_id": "ID notifica non valido",
"error.invalid_device_id": "ID dispositivo non valido",
"error.task_not_found": "Attività non trovata",
"error.residence_not_found": "Immobile non trovato",
"error.contractor_not_found": "Fornitore non trovato",
@@ -43,7 +41,6 @@
"error.user_not_found": "Utente non trovato",
"error.share_code_invalid": "Codice di condivisione non valido",
"error.share_code_expired": "Il codice di condivisione è scaduto",
"error.task_access_denied": "Non hai accesso a questa attività",
"error.residence_access_denied": "Non hai accesso a questo immobile",
"error.contractor_access_denied": "Non hai accesso a questo fornitore",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "Impossibile rimuovere il proprietario dell'immobile",
"error.user_already_member": "L'utente è già membro di questo immobile",
"error.properties_limit_reached": "Hai raggiunto il numero massimo di immobili per il tuo abbonamento",
"error.task_already_cancelled": "L'attività è già stata annullata",
"error.task_already_archived": "L'attività è già stata archiviata",
"error.failed_to_parse_form": "Impossibile analizzare il modulo multipart",
"error.task_id_required": "task_id è obbligatorio",
"error.invalid_task_id_value": "task_id non valido",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "residence_id non valido",
"error.title_required": "title è obbligatorio",
"error.failed_to_upload_file": "Impossibile caricare il file",
"message.logged_out": "Disconnessione avvenuta con successo",
"message.email_verified": "Email verificata con successo",
"message.verification_email_sent": "Email di verifica inviata",
"message.password_reset_email_sent": "Se esiste un account con quell'email, è stato inviato un codice di reimpostazione password.",
"message.reset_code_verified": "Codice verificato con successo",
"message.password_reset_success": "Password reimpostata con successo. Accedi con la tua nuova password.",
"message.task_deleted": "Attività eliminata con successo",
"message.task_in_progress": "Attività contrassegnata come in corso",
"message.task_cancelled": "Attività annullata",
@@ -79,46 +72,35 @@
"message.task_archived": "Attività archiviata",
"message.task_unarchived": "Attività ripristinata dall'archivio",
"message.completion_deleted": "Completamento eliminato con successo",
"message.residence_deleted": "Immobile eliminato con successo",
"message.user_removed": "Utente rimosso dall'immobile",
"message.tasks_report_generated": "Report attività generato con successo",
"message.tasks_report_sent": "Report attività generato e inviato a {{.Email}}",
"message.tasks_report_email_failed": "Report attività generato ma l'email non è stata inviata",
"message.contractor_deleted": "Fornitore eliminato con successo",
"message.document_deleted": "Documento eliminato con successo",
"message.document_activated": "Documento attivato",
"message.document_deactivated": "Documento disattivato",
"message.notification_marked_read": "Notifica contrassegnata come letta",
"message.all_notifications_marked_read": "Tutte le notifiche contrassegnate come lette",
"message.device_removed": "Dispositivo rimosso",
"message.subscription_upgraded": "Abbonamento aggiornato con successo",
"message.subscription_cancelled": "Abbonamento annullato. Manterrai i vantaggi Pro fino alla fine del periodo di fatturazione.",
"message.subscription_restored": "Abbonamento ripristinato con successo",
"message.file_deleted": "File eliminato con successo",
"message.static_data_refreshed": "Dati statici aggiornati",
"error.notification_not_found": "Notifica non trovata",
"error.invalid_platform": "Piattaforma non valida",
"error.upgrade_trigger_not_found": "Trigger di aggiornamento non trovato",
"error.receipt_data_required": "receipt_data è obbligatorio per iOS",
"error.purchase_token_required": "purchase_token è obbligatorio per Android",
"error.no_file_provided": "Nessun file fornito",
"error.failed_to_fetch_residence_types": "Impossibile recuperare i tipi di immobile",
"error.failed_to_fetch_task_categories": "Impossibile recuperare le categorie di attività",
"error.failed_to_fetch_task_priorities": "Impossibile recuperare le priorità delle attività",
"error.failed_to_fetch_task_frequencies": "Impossibile recuperare le frequenze delle attività",
"error.failed_to_fetch_task_statuses": "Impossibile recuperare gli stati delle attività",
"error.failed_to_fetch_contractor_specialties": "Impossibile recuperare le specializzazioni dei fornitori",
"push.task_due_soon.title": "Attività in Scadenza",
"push.task_due_soon.body": "{{.TaskTitle}} scade {{.DueDate}}",
"push.task_overdue.title": "Attività Scaduta",
@@ -129,22 +111,19 @@
"push.task_assigned.body": "Ti è stata assegnata {{.TaskTitle}}",
"push.residence_shared.title": "Immobile Condiviso",
"push.residence_shared.body": "{{.UserName}} ha condiviso {{.ResidenceName}} con te",
"email.welcome.subject": "Benvenuto su honeyDue!",
"email.verification.subject": "Verifica la Tua Email",
"email.password_reset.subject": "Codice di Reimpostazione Password",
"email.tasks_report.subject": "Report Attività per {{.ResidenceName}}",
"lookup.residence_type.house": "Casa",
"lookup.residence_type.apartment": "Appartamento",
"lookup.residence_type.condo": "Condominio",
"lookup.residence_type.townhouse": "Villetta a Schiera",
"lookup.residence_type.mobile_home": "Casa Mobile",
"lookup.residence_type.townhouse": "Villetta a schiera",
"lookup.residence_type.mobile_home": "Casa mobile",
"lookup.residence_type.other": "Altro",
"lookup.task_category.plumbing": "Idraulica",
"lookup.task_category.electrical": "Elettricità",
"lookup.task_category.hvac": "Climatizzazione",
"lookup.task_category.electrical": "Elettrico",
"lookup.task_category.hvac": "HVAC",
"lookup.task_category.appliances": "Elettrodomestici",
"lookup.task_category.exterior": "Esterno",
"lookup.task_category.interior": "Interno",
@@ -154,38 +133,115 @@
"lookup.task_category.pest_control": "Disinfestazione",
"lookup.task_category.seasonal": "Stagionale",
"lookup.task_category.other": "Altro",
"lookup.task_priority.low": "Bassa",
"lookup.task_priority.medium": "Media",
"lookup.task_priority.high": "Alta",
"lookup.task_priority.urgent": "Urgente",
"lookup.task_status.pending": "In Attesa",
"lookup.task_status.in_progress": "In Corso",
"lookup.task_status.completed": "Completata",
"lookup.task_status.cancelled": "Annullata",
"lookup.task_status.archived": "Archiviata",
"lookup.task_frequency.once": "Una Volta",
"lookup.task_frequency.daily": "Giornaliera",
"lookup.task_frequency.once": "Una volta",
"lookup.task_frequency.daily": "Giornaliero",
"lookup.task_frequency.weekly": "Settimanale",
"lookup.task_frequency.biweekly": "Ogni 2 Settimane",
"lookup.task_frequency.monthly": "Mensile",
"lookup.task_frequency.quarterly": "Trimestrale",
"lookup.task_frequency.semiannually": "Ogni 6 Mesi",
"lookup.task_frequency.annually": "Annuale",
"lookup.contractor_specialty.plumber": "Idraulico",
"lookup.contractor_specialty.electrician": "Elettricista",
"lookup.contractor_specialty.hvac_technician": "Tecnico Climatizzazione",
"lookup.contractor_specialty.hvac_technician": "Tecnico HVAC",
"lookup.contractor_specialty.handyman": "Tuttofare",
"lookup.contractor_specialty.landscaper": "Giardiniere",
"lookup.contractor_specialty.roofer": "Lattoniere",
"lookup.contractor_specialty.roofer": "Conciatetti",
"lookup.contractor_specialty.painter": "Imbianchino",
"lookup.contractor_specialty.carpenter": "Falegname",
"lookup.contractor_specialty.pest_control": "Disinfestazione",
"lookup.contractor_specialty.cleaning": "Pulizia",
"lookup.contractor_specialty.pool_service": "Manutenzione Piscine",
"lookup.contractor_specialty.general_contractor": "Impresa Generale",
"lookup.contractor_specialty.other": "Altro"
"lookup.contractor_specialty.pool_service": "Servizio piscina",
"lookup.contractor_specialty.general_contractor": "Imprenditore generale",
"lookup.contractor_specialty.other": "Altro",
"suggestion.reason.has_pool": "La tua casa ha una piscina",
"suggestion.reason.has_sprinkler_system": "La tua casa ha un impianto di irrigazione",
"suggestion.reason.has_septic": "La tua casa ha una fossa settica",
"suggestion.reason.has_fireplace": "La tua casa ha un camino",
"suggestion.reason.has_garage": "La tua casa ha un garage",
"suggestion.reason.has_basement": "La tua casa ha un seminterrato",
"suggestion.reason.has_attic": "La tua casa ha una soffitta",
"suggestion.reason.heating_type": "Corrisponde al tuo impianto di riscaldamento",
"suggestion.reason.cooling_type": "Corrisponde al tuo impianto di raffreddamento",
"suggestion.reason.water_heater_type": "Corrisponde al tuo scaldabagno",
"suggestion.reason.roof_type": "Corrisponde al tuo tetto",
"suggestion.reason.exterior_type": "Corrisponde al tuo esterno",
"suggestion.reason.flooring_primary": "Corrisponde alla tua pavimentazione",
"suggestion.reason.landscaping_type": "Corrisponde al tuo giardino",
"suggestion.reason.property_type": "Consigliato per il tuo tipo di immobile",
"suggestion.reason.climate_region": "Consigliato per il tuo clima",
"lookup.residence_type.duplex": "Bifamiliare",
"lookup.residence_type.vacation_home": "Casa vacanze",
"lookup.task_category.general": "Generale",
"lookup.task_frequency.bi_weekly": "Bisettimanale",
"lookup.task_frequency.semi_annually": "Semestrale",
"lookup.task_frequency.custom": "Personalizzato",
"lookup.contractor_specialty.appliance_repair": "Riparazione elettrodomestici",
"lookup.contractor_specialty.cleaner": "Addetto alle pulizie",
"lookup.contractor_specialty.locksmith": "Fabbro",
"lookup.home_profile.gas_furnace": "Caldaia a gas",
"lookup.home_profile.electric_furnace": "Caldaia elettrica",
"lookup.home_profile.heat_pump": "Pompa di calore",
"lookup.home_profile.boiler": "Caldaia",
"lookup.home_profile.radiant": "Radiante",
"lookup.home_profile.other": "Altro",
"lookup.home_profile.central_ac": "Climatizzatore centrale",
"lookup.home_profile.window_ac": "Climatizzatore a finestra",
"lookup.home_profile.evaporative": "Evaporativo",
"lookup.home_profile.none": "Nessuno",
"lookup.home_profile.tank_gas": "Serbatoio (gas)",
"lookup.home_profile.tank_electric": "Serbatoio (elettrico)",
"lookup.home_profile.tankless_gas": "Senza serbatoio (gas)",
"lookup.home_profile.tankless_electric": "Senza serbatoio (elettrico)",
"lookup.home_profile.solar": "Solare",
"lookup.home_profile.asphalt_shingle": "Tegola bituminosa",
"lookup.home_profile.metal": "Metallo",
"lookup.home_profile.tile": "Tegola",
"lookup.home_profile.slate": "Ardesia",
"lookup.home_profile.wood_shake": "Scandola di legno",
"lookup.home_profile.flat": "Piatto",
"lookup.home_profile.brick": "Mattone",
"lookup.home_profile.vinyl_siding": "Rivestimento in vinile",
"lookup.home_profile.wood_siding": "Rivestimento in legno",
"lookup.home_profile.stucco": "Stucco",
"lookup.home_profile.stone": "Pietra",
"lookup.home_profile.fiber_cement": "Fibrocemento",
"lookup.home_profile.hardwood": "Legno duro",
"lookup.home_profile.laminate": "Laminato",
"lookup.home_profile.carpet": "Moquette",
"lookup.home_profile.vinyl": "Vinile",
"lookup.home_profile.concrete": "Cemento",
"lookup.home_profile.lawn": "Prato",
"lookup.home_profile.desert": "Deserto",
"lookup.home_profile.xeriscape": "Xeriscaping",
"lookup.home_profile.garden": "Giardino",
"lookup.home_profile.mixed": "Misto",
"lookup.document_type.warranty": "Garanzia",
"lookup.document_type.manual": "Manuale d'uso",
"lookup.document_type.receipt": "Ricevuta/Fattura",
"lookup.document_type.inspection": "Rapporto di ispezione",
"lookup.document_type.permit": "Permesso",
"lookup.document_type.deed": "Atto/Titolo",
"lookup.document_type.insurance": "Assicurazione",
"lookup.document_type.contract": "Contratto",
"lookup.document_type.photo": "Foto",
"lookup.document_type.other": "Altro",
"lookup.document_category.appliance": "Elettrodomestico",
"lookup.document_category.hvac": "HVAC",
"lookup.document_category.plumbing": "Idraulica",
"lookup.document_category.electrical": "Elettrico",
"lookup.document_category.roofing": "Tetto",
"lookup.document_category.structural": "Strutturale",
"lookup.document_category.landscaping": "Giardinaggio",
"lookup.document_category.general": "Generale",
"lookup.document_category.other": "Altro"
}
+90 -34
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "Google サインインが設定されていません",
"error.google_signin_failed": "Google サインインに失敗しました",
"error.invalid_google_token": "無効な Google ID トークンです",
"error.invalid_task_id": "無効なタスクIDです",
"error.invalid_residence_id": "無効な物件IDです",
"error.invalid_contractor_id": "無効な業者IDです",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "無効なユーザーIDです",
"error.invalid_notification_id": "無効な通知IDです",
"error.invalid_device_id": "無効なデバイスIDです",
"error.task_not_found": "タスクが見つかりません",
"error.residence_not_found": "物件が見つかりません",
"error.contractor_not_found": "業者が見つかりません",
@@ -43,7 +41,6 @@
"error.user_not_found": "ユーザーが見つかりません",
"error.share_code_invalid": "無効な共有コードです",
"error.share_code_expired": "共有コードの有効期限が切れています",
"error.task_access_denied": "このタスクへのアクセス権がありません",
"error.residence_access_denied": "この物件へのアクセス権がありません",
"error.contractor_access_denied": "この業者へのアクセス権がありません",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "物件のオーナーを削除することはできません",
"error.user_already_member": "このユーザーは既にこの物件のメンバーです",
"error.properties_limit_reached": "サブスクリプションで許可されている物件の最大数に達しました",
"error.task_already_cancelled": "タスクは既にキャンセルされています",
"error.task_already_archived": "タスクは既にアーカイブされています",
"error.failed_to_parse_form": "マルチパートフォームの解析に失敗しました",
"error.task_id_required": "task_id は必須です",
"error.invalid_task_id_value": "無効な task_id です",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "無効な residence_id です",
"error.title_required": "タイトルは必須です",
"error.failed_to_upload_file": "ファイルのアップロードに失敗しました",
"message.logged_out": "ログアウトしました",
"message.email_verified": "メールアドレスの認証が完了しました",
"message.verification_email_sent": "認証メールを送信しました",
"message.password_reset_email_sent": "該当するアカウントが存在する場合、パスワードリセットコードが送信されました。",
"message.reset_code_verified": "コードの認証が完了しました",
"message.password_reset_success": "パスワードのリセットが完了しました。新しいパスワードでログインしてください。",
"message.task_deleted": "タスクを削除しました",
"message.task_in_progress": "タスクを進行中に設定しました",
"message.task_cancelled": "タスクをキャンセルしました",
@@ -79,46 +72,35 @@
"message.task_archived": "タスクをアーカイブしました",
"message.task_unarchived": "タスクのアーカイブを解除しました",
"message.completion_deleted": "完了記録を削除しました",
"message.residence_deleted": "物件を削除しました",
"message.user_removed": "ユーザーを物件から削除しました",
"message.tasks_report_generated": "タスクレポートを生成しました",
"message.tasks_report_sent": "タスクレポートを生成し、{{.Email}} に送信しました",
"message.tasks_report_email_failed": "タスクレポートは生成されましたが、メールの送信に失敗しました",
"message.contractor_deleted": "業者を削除しました",
"message.document_deleted": "書類を削除しました",
"message.document_activated": "書類を有効化しました",
"message.document_deactivated": "書類を無効化しました",
"message.notification_marked_read": "通知を既読にしました",
"message.all_notifications_marked_read": "すべての通知を既読にしました",
"message.device_removed": "デバイスを削除しました",
"message.subscription_upgraded": "サブスクリプションをアップグレードしました",
"message.subscription_cancelled": "サブスクリプションをキャンセルしました。請求期間終了まで Pro 機能をご利用いただけます。",
"message.subscription_restored": "サブスクリプションを復元しました",
"message.file_deleted": "ファイルを削除しました",
"message.static_data_refreshed": "静的データを更新しました",
"error.notification_not_found": "通知が見つかりません",
"error.invalid_platform": "無効なプラットフォームです",
"error.upgrade_trigger_not_found": "アップグレードトリガーが見つかりません",
"error.receipt_data_required": "iOS の場合、receipt_data は必須です",
"error.purchase_token_required": "Android の場合、purchase_token は必須です",
"error.no_file_provided": "ファイルが提供されていません",
"error.failed_to_fetch_residence_types": "物件タイプの取得に失敗しました",
"error.failed_to_fetch_task_categories": "タスクカテゴリの取得に失敗しました",
"error.failed_to_fetch_task_priorities": "タスク優先度の取得に失敗しました",
"error.failed_to_fetch_task_frequencies": "タスク頻度の取得に失敗しました",
"error.failed_to_fetch_task_statuses": "タスクステータスの取得に失敗しました",
"error.failed_to_fetch_contractor_specialties": "業者専門分野の取得に失敗しました",
"push.task_due_soon.title": "タスクの期限が近づいています",
"push.task_due_soon.body": "{{.TaskTitle}} の期限は {{.DueDate}} です",
"push.task_overdue.title": "期限切れのタスク",
@@ -129,19 +111,16 @@
"push.task_assigned.body": "{{.TaskTitle}} に割り当てられました",
"push.residence_shared.title": "物件が共有されました",
"push.residence_shared.body": "{{.UserName}} が {{.ResidenceName}} を共有しました",
"email.welcome.subject": "honeyDue へようこそ!",
"email.verification.subject": "メールアドレスの認証",
"email.password_reset.subject": "パスワードリセットコード",
"email.tasks_report.subject": "{{.ResidenceName}} のタスクレポート",
"lookup.residence_type.house": "一戸建て",
"lookup.residence_type.apartment": "アパート",
"lookup.residence_type.condo": "マンション",
"lookup.residence_type.condo": "分譲マンション",
"lookup.residence_type.townhouse": "タウンハウス",
"lookup.residence_type.mobile_home": "移動式住宅",
"lookup.residence_type.mobile_home": "モバイルホーム",
"lookup.residence_type.other": "その他",
"lookup.task_category.plumbing": "配管",
"lookup.task_category.electrical": "電気",
"lookup.task_category.hvac": "空調",
@@ -154,19 +133,16 @@
"lookup.task_category.pest_control": "害虫駆除",
"lookup.task_category.seasonal": "季節",
"lookup.task_category.other": "その他",
"lookup.task_priority.low": "低",
"lookup.task_priority.medium": "中",
"lookup.task_priority.high": "高",
"lookup.task_priority.urgent": "緊急",
"lookup.task_status.pending": "保留中",
"lookup.task_status.in_progress": "進行中",
"lookup.task_status.completed": "完了",
"lookup.task_status.cancelled": "キャンセル",
"lookup.task_status.archived": "アーカイブ",
"lookup.task_frequency.once": "一度のみ",
"lookup.task_frequency.once": "1回",
"lookup.task_frequency.daily": "毎日",
"lookup.task_frequency.weekly": "毎週",
"lookup.task_frequency.biweekly": "2週間ごと",
@@ -174,18 +150,98 @@
"lookup.task_frequency.quarterly": "四半期ごと",
"lookup.task_frequency.semiannually": "半年ごと",
"lookup.task_frequency.annually": "毎年",
"lookup.contractor_specialty.plumber": "配管工",
"lookup.contractor_specialty.electrician": "電気工事士",
"lookup.contractor_specialty.hvac_technician": "空調技術者",
"lookup.contractor_specialty.electrician": "電気技師",
"lookup.contractor_specialty.hvac_technician": "空調技",
"lookup.contractor_specialty.handyman": "便利屋",
"lookup.contractor_specialty.landscaper": "造園業者",
"lookup.contractor_specialty.roofer": "屋根職人",
"lookup.contractor_specialty.painter": "塗装",
"lookup.contractor_specialty.painter": "塗装業者",
"lookup.contractor_specialty.carpenter": "大工",
"lookup.contractor_specialty.pest_control": "害虫駆除業者",
"lookup.contractor_specialty.pest_control": "害虫駆除",
"lookup.contractor_specialty.cleaning": "清掃業者",
"lookup.contractor_specialty.pool_service": "プールサービス",
"lookup.contractor_specialty.general_contractor": "総合建設業者",
"lookup.contractor_specialty.other": "その他"
"lookup.contractor_specialty.general_contractor": "総合請負業者",
"lookup.contractor_specialty.other": "その他",
"suggestion.reason.has_pool": "ご自宅にプールがあります",
"suggestion.reason.has_sprinkler_system": "ご自宅にスプリンクラーがあります",
"suggestion.reason.has_septic": "ご自宅に浄化槽があります",
"suggestion.reason.has_fireplace": "ご自宅に暖炉があります",
"suggestion.reason.has_garage": "ご自宅にガレージがあります",
"suggestion.reason.has_basement": "ご自宅に地下室があります",
"suggestion.reason.has_attic": "ご自宅に屋根裏があります",
"suggestion.reason.heating_type": "暖房設備に合っています",
"suggestion.reason.cooling_type": "冷房設備に合っています",
"suggestion.reason.water_heater_type": "給湯器に合っています",
"suggestion.reason.roof_type": "屋根に合っています",
"suggestion.reason.exterior_type": "外装に合っています",
"suggestion.reason.flooring_primary": "床材に合っています",
"suggestion.reason.landscaping_type": "造園に合っています",
"suggestion.reason.property_type": "ご自宅の種類におすすめです",
"suggestion.reason.climate_region": "お住まいの気候におすすめです",
"lookup.residence_type.duplex": "二世帯住宅",
"lookup.residence_type.vacation_home": "別荘",
"lookup.task_category.general": "一般",
"lookup.task_frequency.bi_weekly": "隔週",
"lookup.task_frequency.semi_annually": "半年ごと",
"lookup.task_frequency.custom": "カスタム",
"lookup.contractor_specialty.appliance_repair": "家電修理",
"lookup.contractor_specialty.cleaner": "清掃業者",
"lookup.contractor_specialty.locksmith": "錠前師",
"lookup.home_profile.gas_furnace": "ガス炉",
"lookup.home_profile.electric_furnace": "電気炉",
"lookup.home_profile.heat_pump": "ヒートポンプ",
"lookup.home_profile.boiler": "ボイラー",
"lookup.home_profile.radiant": "放射式",
"lookup.home_profile.other": "その他",
"lookup.home_profile.central_ac": "セントラルエアコン",
"lookup.home_profile.window_ac": "窓用エアコン",
"lookup.home_profile.evaporative": "気化式",
"lookup.home_profile.none": "なし",
"lookup.home_profile.tank_gas": "タンク式(ガス)",
"lookup.home_profile.tank_electric": "タンク式(電気)",
"lookup.home_profile.tankless_gas": "タンクレス(ガス)",
"lookup.home_profile.tankless_electric": "タンクレス(電気)",
"lookup.home_profile.solar": "ソーラー",
"lookup.home_profile.asphalt_shingle": "アスファルトシングル",
"lookup.home_profile.metal": "金属",
"lookup.home_profile.tile": "タイル",
"lookup.home_profile.slate": "スレート",
"lookup.home_profile.wood_shake": "木製シェイク",
"lookup.home_profile.flat": "平型",
"lookup.home_profile.brick": "レンガ",
"lookup.home_profile.vinyl_siding": "ビニールサイディング",
"lookup.home_profile.wood_siding": "木製サイディング",
"lookup.home_profile.stucco": "スタッコ",
"lookup.home_profile.stone": "石",
"lookup.home_profile.fiber_cement": "繊維強化セメント",
"lookup.home_profile.hardwood": "無垢材",
"lookup.home_profile.laminate": "ラミネート",
"lookup.home_profile.carpet": "カーペット",
"lookup.home_profile.vinyl": "ビニール",
"lookup.home_profile.concrete": "コンクリート",
"lookup.home_profile.lawn": "芝生",
"lookup.home_profile.desert": "砂漠",
"lookup.home_profile.xeriscape": "ゼリスケープ",
"lookup.home_profile.garden": "庭園",
"lookup.home_profile.mixed": "混合",
"lookup.document_type.warranty": "保証",
"lookup.document_type.manual": "ユーザーマニュアル",
"lookup.document_type.receipt": "領収書/請求書",
"lookup.document_type.inspection": "点検報告書",
"lookup.document_type.permit": "許可証",
"lookup.document_type.deed": "権利証/証書",
"lookup.document_type.insurance": "保険",
"lookup.document_type.contract": "契約",
"lookup.document_type.photo": "写真",
"lookup.document_type.other": "その他",
"lookup.document_category.appliance": "家電",
"lookup.document_category.hvac": "空調",
"lookup.document_category.plumbing": "配管",
"lookup.document_category.electrical": "電気",
"lookup.document_category.roofing": "屋根",
"lookup.document_category.structural": "構造",
"lookup.document_category.landscaping": "造園",
"lookup.document_category.general": "一般",
"lookup.document_category.other": "その他"
}
+89 -33
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "Google 로그인이 설정되지 않았습니다",
"error.google_signin_failed": "Google 로그인에 실패했습니다",
"error.invalid_google_token": "유효하지 않은 Google 인증 토큰입니다",
"error.invalid_task_id": "유효하지 않은 작업 ID입니다",
"error.invalid_residence_id": "유효하지 않은 주거지 ID입니다",
"error.invalid_contractor_id": "유효하지 않은 계약업체 ID입니다",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "유효하지 않은 사용자 ID입니다",
"error.invalid_notification_id": "유효하지 않은 알림 ID입니다",
"error.invalid_device_id": "유효하지 않은 기기 ID입니다",
"error.task_not_found": "작업을 찾을 수 없습니다",
"error.residence_not_found": "주거지를 찾을 수 없습니다",
"error.contractor_not_found": "계약업체를 찾을 수 없습니다",
@@ -43,7 +41,6 @@
"error.user_not_found": "사용자를 찾을 수 없습니다",
"error.share_code_invalid": "유효하지 않은 공유 코드입니다",
"error.share_code_expired": "공유 코드가 만료되었습니다",
"error.task_access_denied": "이 작업에 접근할 권한이 없습니다",
"error.residence_access_denied": "이 주거지에 접근할 권한이 없습니다",
"error.contractor_access_denied": "이 계약업체에 접근할 권한이 없습니다",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "주거지 소유자는 삭제할 수 없습니다",
"error.user_already_member": "이미 이 주거지의 멤버입니다",
"error.properties_limit_reached": "구독 플랜의 최대 주거지 수에 도달했습니다",
"error.task_already_cancelled": "이미 취소된 작업입니다",
"error.task_already_archived": "이미 보관된 작업입니다",
"error.failed_to_parse_form": "멀티파트 폼 파싱에 실패했습니다",
"error.task_id_required": "task_id가 필요합니다",
"error.invalid_task_id_value": "유효하지 않은 task_id 값입니다",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "유효하지 않은 residence_id 값입니다",
"error.title_required": "제목이 필요합니다",
"error.failed_to_upload_file": "파일 업로드에 실패했습니다",
"message.logged_out": "로그아웃되었습니다",
"message.email_verified": "이메일이 인증되었습니다",
"message.verification_email_sent": "인증 이메일이 발송되었습니다",
"message.password_reset_email_sent": "해당 이메일로 등록된 계정이 있는 경우 비밀번호 재설정 코드가 발송되었습니다.",
"message.reset_code_verified": "코드가 인증되었습니다",
"message.password_reset_success": "비밀번호가 재설정되었습니다. 새 비밀번호로 로그인해주세요.",
"message.task_deleted": "작업이 삭제되었습니다",
"message.task_in_progress": "작업이 진행 중으로 표시되었습니다",
"message.task_cancelled": "작업이 취소되었습니다",
@@ -79,46 +72,35 @@
"message.task_archived": "작업이 보관되었습니다",
"message.task_unarchived": "작업 보관이 해제되었습니다",
"message.completion_deleted": "완료 기록이 삭제되었습니다",
"message.residence_deleted": "주거지가 삭제되었습니다",
"message.user_removed": "주거지에서 사용자가 제거되었습니다",
"message.tasks_report_generated": "작업 보고서가 생성되었습니다",
"message.tasks_report_sent": "작업 보고서가 생성되어 {{.Email}}로 발송되었습니다",
"message.tasks_report_email_failed": "작업 보고서가 생성되었지만 이메일 발송에 실패했습니다",
"message.contractor_deleted": "계약업체가 삭제되었습니다",
"message.document_deleted": "문서가 삭제되었습니다",
"message.document_activated": "문서가 활성화되었습니다",
"message.document_deactivated": "문서가 비활성화되었습니다",
"message.notification_marked_read": "알림이 읽음으로 표시되었습니다",
"message.all_notifications_marked_read": "모든 알림이 읽음으로 표시되었습니다",
"message.device_removed": "기기가 제거되었습니다",
"message.subscription_upgraded": "구독이 업그레이드되었습니다",
"message.subscription_cancelled": "구독이 취소되었습니다. 결제 기간이 종료될 때까지 Pro 혜택을 유지하실 수 있습니다.",
"message.subscription_restored": "구독이 복원되었습니다",
"message.file_deleted": "파일이 삭제되었습니다",
"message.static_data_refreshed": "정적 데이터가 새로고침되었습니다",
"error.notification_not_found": "알림을 찾을 수 없습니다",
"error.invalid_platform": "유효하지 않은 플랫폼입니다",
"error.upgrade_trigger_not_found": "업그레이드 트리거를 찾을 수 없습니다",
"error.receipt_data_required": "iOS의 경우 receipt_data가 필요합니다",
"error.purchase_token_required": "Android의 경우 purchase_token이 필요합니다",
"error.no_file_provided": "파일이 제공되지 않았습니다",
"error.failed_to_fetch_residence_types": "주거지 유형을 가져오는데 실패했습니다",
"error.failed_to_fetch_task_categories": "작업 카테고리를 가져오는데 실패했습니다",
"error.failed_to_fetch_task_priorities": "작업 우선순위를 가져오는데 실패했습니다",
"error.failed_to_fetch_task_frequencies": "작업 빈도를 가져오는데 실패했습니다",
"error.failed_to_fetch_task_statuses": "작업 상태를 가져오는데 실패했습니다",
"error.failed_to_fetch_contractor_specialties": "계약업체 전문 분야를 가져오는데 실패했습니다",
"push.task_due_soon.title": "작업 마감 임박",
"push.task_due_soon.body": "{{.TaskTitle}}의 마감일은 {{.DueDate}}입니다",
"push.task_overdue.title": "지연된 작업",
@@ -129,23 +111,20 @@
"push.task_assigned.body": "{{.TaskTitle}}이(가) 할당되었습니다",
"push.residence_shared.title": "주거지 공유",
"push.residence_shared.body": "{{.UserName}}님이 {{.ResidenceName}}을(를) 공유했습니다",
"email.welcome.subject": "honeyDue에 오신 것을 환영합니다!",
"email.verification.subject": "이메일 인증",
"email.password_reset.subject": "비밀번호 재설정 코드",
"email.tasks_report.subject": "{{.ResidenceName}} 작업 보고서",
"lookup.residence_type.house": "단독주택",
"lookup.residence_type.house": "주택",
"lookup.residence_type.apartment": "아파트",
"lookup.residence_type.condo": "콘도",
"lookup.residence_type.townhouse": "타운하우스",
"lookup.residence_type.mobile_home": "이동식 주택",
"lookup.residence_type.other": "기타",
"lookup.task_category.plumbing": "배관",
"lookup.task_category.electrical": "전기",
"lookup.task_category.hvac": "냉난방",
"lookup.task_category.appliances": "가전제품",
"lookup.task_category.appliances": "가전",
"lookup.task_category.exterior": "외부",
"lookup.task_category.interior": "내부",
"lookup.task_category.landscaping": "조경",
@@ -154,18 +133,15 @@
"lookup.task_category.pest_control": "해충 방제",
"lookup.task_category.seasonal": "계절별",
"lookup.task_category.other": "기타",
"lookup.task_priority.low": "낮음",
"lookup.task_priority.medium": "보통",
"lookup.task_priority.high": "높음",
"lookup.task_priority.urgent": "긴급",
"lookup.task_status.pending": "대기 중",
"lookup.task_status.in_progress": "진행 중",
"lookup.task_status.completed": "완료",
"lookup.task_status.cancelled": "취소됨",
"lookup.task_status.archived": "보관됨",
"lookup.task_frequency.once": "한 번",
"lookup.task_frequency.daily": "매일",
"lookup.task_frequency.weekly": "매주",
@@ -174,18 +150,98 @@
"lookup.task_frequency.quarterly": "분기별",
"lookup.task_frequency.semiannually": "6개월마다",
"lookup.task_frequency.annually": "매년",
"lookup.contractor_specialty.plumber": "배관공",
"lookup.contractor_specialty.electrician": "전기 기사",
"lookup.contractor_specialty.hvac_technician": "냉난방 기사",
"lookup.contractor_specialty.handyman": "리공",
"lookup.contractor_specialty.handyman": "리공",
"lookup.contractor_specialty.landscaper": "조경사",
"lookup.contractor_specialty.roofer": "지붕",
"lookup.contractor_specialty.painter": "도배공",
"lookup.contractor_specialty.roofer": "지붕 기술자",
"lookup.contractor_specialty.painter": "페인터",
"lookup.contractor_specialty.carpenter": "목수",
"lookup.contractor_specialty.pest_control": "해충 방제",
"lookup.contractor_specialty.cleaning": "청소",
"lookup.contractor_specialty.pool_service": "수영장 관리",
"lookup.contractor_specialty.general_contractor": "종합 건설업",
"lookup.contractor_specialty.other": "기타"
"lookup.contractor_specialty.pool_service": "수영장 서비스",
"lookup.contractor_specialty.general_contractor": "종합 건설업",
"lookup.contractor_specialty.other": "기타",
"suggestion.reason.has_pool": "집에 수영장이 있습니다",
"suggestion.reason.has_sprinkler_system": "집에 스프링클러 시스템이 있습니다",
"suggestion.reason.has_septic": "집에 정화조가 있습니다",
"suggestion.reason.has_fireplace": "집에 벽난로가 있습니다",
"suggestion.reason.has_garage": "집에 차고가 있습니다",
"suggestion.reason.has_basement": "집에 지하실이 있습니다",
"suggestion.reason.has_attic": "집에 다락방이 있습니다",
"suggestion.reason.heating_type": "난방 시스템과 일치합니다",
"suggestion.reason.cooling_type": "냉방 시스템과 일치합니다",
"suggestion.reason.water_heater_type": "온수기와 일치합니다",
"suggestion.reason.roof_type": "지붕과 일치합니다",
"suggestion.reason.exterior_type": "외장과 일치합니다",
"suggestion.reason.flooring_primary": "바닥재와 일치합니다",
"suggestion.reason.landscaping_type": "조경과 일치합니다",
"suggestion.reason.property_type": "주택 유형에 추천됩니다",
"suggestion.reason.climate_region": "거주 지역 기후에 추천됩니다",
"lookup.residence_type.duplex": "듀플렉스",
"lookup.residence_type.vacation_home": "별장",
"lookup.task_category.general": "일반",
"lookup.task_frequency.bi_weekly": "격주",
"lookup.task_frequency.semi_annually": "반기별",
"lookup.task_frequency.custom": "사용자 지정",
"lookup.contractor_specialty.appliance_repair": "가전 수리",
"lookup.contractor_specialty.cleaner": "청소부",
"lookup.contractor_specialty.locksmith": "열쇠공",
"lookup.home_profile.gas_furnace": "가스 난로",
"lookup.home_profile.electric_furnace": "전기 난로",
"lookup.home_profile.heat_pump": "열펌프",
"lookup.home_profile.boiler": "보일러",
"lookup.home_profile.radiant": "복사식",
"lookup.home_profile.other": "기타",
"lookup.home_profile.central_ac": "중앙 에어컨",
"lookup.home_profile.window_ac": "창문형 에어컨",
"lookup.home_profile.evaporative": "증발식",
"lookup.home_profile.none": "없음",
"lookup.home_profile.tank_gas": "탱크식(가스)",
"lookup.home_profile.tank_electric": "탱크식(전기)",
"lookup.home_profile.tankless_gas": "탱크리스(가스)",
"lookup.home_profile.tankless_electric": "탱크리스(전기)",
"lookup.home_profile.solar": "태양광",
"lookup.home_profile.asphalt_shingle": "아스팔트 슁글",
"lookup.home_profile.metal": "금속",
"lookup.home_profile.tile": "타일",
"lookup.home_profile.slate": "슬레이트",
"lookup.home_profile.wood_shake": "목재 셰이크",
"lookup.home_profile.flat": "평지붕",
"lookup.home_profile.brick": "벽돌",
"lookup.home_profile.vinyl_siding": "비닐 사이딩",
"lookup.home_profile.wood_siding": "목재 사이딩",
"lookup.home_profile.stucco": "스투코",
"lookup.home_profile.stone": "석재",
"lookup.home_profile.fiber_cement": "섬유 시멘트",
"lookup.home_profile.hardwood": "원목",
"lookup.home_profile.laminate": "라미네이트",
"lookup.home_profile.carpet": "카펫",
"lookup.home_profile.vinyl": "비닐",
"lookup.home_profile.concrete": "콘크리트",
"lookup.home_profile.lawn": "잔디",
"lookup.home_profile.desert": "사막",
"lookup.home_profile.xeriscape": "제리스케이프",
"lookup.home_profile.garden": "정원",
"lookup.home_profile.mixed": "혼합",
"lookup.document_type.warranty": "보증",
"lookup.document_type.manual": "사용 설명서",
"lookup.document_type.receipt": "영수증/송장",
"lookup.document_type.inspection": "점검 보고서",
"lookup.document_type.permit": "허가증",
"lookup.document_type.deed": "증서/권리증",
"lookup.document_type.insurance": "보험",
"lookup.document_type.contract": "계약",
"lookup.document_type.photo": "사진",
"lookup.document_type.other": "기타",
"lookup.document_category.appliance": "가전",
"lookup.document_category.hvac": "냉난방",
"lookup.document_category.plumbing": "배관",
"lookup.document_category.electrical": "전기",
"lookup.document_category.roofing": "지붕",
"lookup.document_category.structural": "구조",
"lookup.document_category.landscaping": "조경",
"lookup.document_category.general": "일반",
"lookup.document_category.other": "기타"
}
+93 -37
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "Google Sign In is niet geconfigureerd",
"error.google_signin_failed": "Google Sign In mislukt",
"error.invalid_google_token": "Ongeldig Google identiteitstoken",
"error.invalid_task_id": "Ongeldig taak-ID",
"error.invalid_residence_id": "Ongeldig woning-ID",
"error.invalid_contractor_id": "Ongeldig aannemer-ID",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "Ongeldig gebruikers-ID",
"error.invalid_notification_id": "Ongeldig notificatie-ID",
"error.invalid_device_id": "Ongeldig apparaat-ID",
"error.task_not_found": "Taak niet gevonden",
"error.residence_not_found": "Woning niet gevonden",
"error.contractor_not_found": "Aannemer niet gevonden",
@@ -43,7 +41,6 @@
"error.user_not_found": "Gebruiker niet gevonden",
"error.share_code_invalid": "Ongeldige deelcode",
"error.share_code_expired": "Deelcode is verlopen",
"error.task_access_denied": "U heeft geen toegang tot deze taak",
"error.residence_access_denied": "U heeft geen toegang tot deze woning",
"error.contractor_access_denied": "U heeft geen toegang tot deze aannemer",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "Kan de woningeigenaar niet verwijderen",
"error.user_already_member": "Gebruiker is al lid van deze woning",
"error.properties_limit_reached": "U heeft het maximale aantal woningen voor uw abonnement bereikt",
"error.task_already_cancelled": "Taak is al geannuleerd",
"error.task_already_archived": "Taak is al gearchiveerd",
"error.failed_to_parse_form": "Multipart formulier parsen mislukt",
"error.task_id_required": "task_id is verplicht",
"error.invalid_task_id_value": "Ongeldig task_id",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "Ongeldig residence_id",
"error.title_required": "titel is verplicht",
"error.failed_to_upload_file": "Bestand uploaden mislukt",
"message.logged_out": "Succesvol uitgelogd",
"message.email_verified": "E-mailadres succesvol geverifieerd",
"message.verification_email_sent": "Verificatie e-mail verzonden",
"message.password_reset_email_sent": "Als er een account met dat e-mailadres bestaat, is er een wachtwoord resetcode verzonden.",
"message.reset_code_verified": "Code succesvol geverifieerd",
"message.password_reset_success": "Wachtwoord succesvol gereset. Log in met uw nieuwe wachtwoord.",
"message.task_deleted": "Taak succesvol verwijderd",
"message.task_in_progress": "Taak gemarkeerd als in uitvoering",
"message.task_cancelled": "Taak geannuleerd",
@@ -79,46 +72,35 @@
"message.task_archived": "Taak gearchiveerd",
"message.task_unarchived": "Taak gearchiveerd ongedaan gemaakt",
"message.completion_deleted": "Voltooiing succesvol verwijderd",
"message.residence_deleted": "Woning succesvol verwijderd",
"message.user_removed": "Gebruiker verwijderd van woning",
"message.tasks_report_generated": "Takenrapport succesvol gegenereerd",
"message.tasks_report_sent": "Takenrapport gegenereerd en verzonden naar {{.Email}}",
"message.tasks_report_email_failed": "Takenrapport gegenereerd maar e-mail kon niet worden verzonden",
"message.contractor_deleted": "Aannemer succesvol verwijderd",
"message.document_deleted": "Document succesvol verwijderd",
"message.document_activated": "Document geactiveerd",
"message.document_deactivated": "Document gedeactiveerd",
"message.notification_marked_read": "Notificatie gemarkeerd als gelezen",
"message.all_notifications_marked_read": "Alle notificaties gemarkeerd als gelezen",
"message.device_removed": "Apparaat verwijderd",
"message.subscription_upgraded": "Abonnement succesvol geüpgraded",
"message.subscription_cancelled": "Abonnement geannuleerd. U behoudt Pro voordelen tot het einde van uw factureringsperiode.",
"message.subscription_restored": "Abonnement succesvol hersteld",
"message.file_deleted": "Bestand succesvol verwijderd",
"message.static_data_refreshed": "Statische gegevens vernieuwd",
"error.notification_not_found": "Notificatie niet gevonden",
"error.invalid_platform": "Ongeldig platform",
"error.upgrade_trigger_not_found": "Upgrade trigger niet gevonden",
"error.receipt_data_required": "receipt_data is verplicht voor iOS",
"error.purchase_token_required": "purchase_token is verplicht voor Android",
"error.no_file_provided": "Geen bestand aangeleverd",
"error.failed_to_fetch_residence_types": "Woningtypes ophalen mislukt",
"error.failed_to_fetch_task_categories": "Taakcategorieën ophalen mislukt",
"error.failed_to_fetch_task_priorities": "Taakprioriteiten ophalen mislukt",
"error.failed_to_fetch_task_frequencies": "Taakfrequenties ophalen mislukt",
"error.failed_to_fetch_task_statuses": "Taakstatussen ophalen mislukt",
"error.failed_to_fetch_contractor_specialties": "Aannemer specialiteiten ophalen mislukt",
"push.task_due_soon.title": "Taak Vervalt Binnenkort",
"push.task_due_soon.body": "{{.TaskTitle}} vervalt {{.DueDate}}",
"push.task_overdue.title": "Verlopen Taak",
@@ -129,55 +111,48 @@
"push.task_assigned.body": "U bent toegewezen aan {{.TaskTitle}}",
"push.residence_shared.title": "Woning Gedeeld",
"push.residence_shared.body": "{{.UserName}} heeft {{.ResidenceName}} met u gedeeld",
"email.welcome.subject": "Welkom bij honeyDue!",
"email.verification.subject": "Verifieer Uw E-mailadres",
"email.password_reset.subject": "Wachtwoord Resetcode",
"email.tasks_report.subject": "Takenrapport voor {{.ResidenceName}}",
"lookup.residence_type.house": "Huis",
"lookup.residence_type.apartment": "Appartement",
"lookup.residence_type.condo": "Appartement met eigen grond",
"lookup.residence_type.condo": "Koopflat",
"lookup.residence_type.townhouse": "Rijtjeshuis",
"lookup.residence_type.mobile_home": "Stacaravan",
"lookup.residence_type.other": "Anders",
"lookup.task_category.plumbing": "Loodgieterij",
"lookup.task_category.electrical": "Elektriciteit",
"lookup.task_category.hvac": "Verwarming en Ventilatie",
"lookup.residence_type.other": "Overig",
"lookup.task_category.plumbing": "Loodgieterswerk",
"lookup.task_category.electrical": "Elektrisch",
"lookup.task_category.hvac": "HVAC",
"lookup.task_category.appliances": "Apparaten",
"lookup.task_category.exterior": "Buitenkant",
"lookup.task_category.interior": "Binnenkant",
"lookup.task_category.exterior": "Buiten",
"lookup.task_category.interior": "Binnen",
"lookup.task_category.landscaping": "Tuinonderhoud",
"lookup.task_category.safety": "Veiligheid",
"lookup.task_category.cleaning": "Schoonmaak",
"lookup.task_category.pest_control": "Ongediertebestrijding",
"lookup.task_category.seasonal": "Seizoensgebonden",
"lookup.task_category.other": "Anders",
"lookup.task_priority.low": "Laag",
"lookup.task_priority.medium": "Gemiddeld",
"lookup.task_priority.high": "Hoog",
"lookup.task_priority.urgent": "Urgent",
"lookup.task_status.pending": "In afwachting",
"lookup.task_status.in_progress": "In uitvoering",
"lookup.task_status.completed": "Voltooid",
"lookup.task_status.cancelled": "Geannuleerd",
"lookup.task_status.archived": "Gearchiveerd",
"lookup.task_frequency.once": "Eenmalig",
"lookup.task_frequency.daily": "Dagelijks",
"lookup.task_frequency.weekly": "Wekelijks",
"lookup.task_frequency.biweekly": "Om de 2 Weken",
"lookup.task_frequency.monthly": "Maandelijks",
"lookup.task_frequency.quarterly": "Per Kwartaal",
"lookup.task_frequency.quarterly": "Per kwartaal",
"lookup.task_frequency.semiannually": "Om de 6 Maanden",
"lookup.task_frequency.annually": "Jaarlijks",
"lookup.contractor_specialty.plumber": "Loodgieter",
"lookup.contractor_specialty.electrician": "Elektricien",
"lookup.contractor_specialty.hvac_technician": "HVAC Monteur",
"lookup.contractor_specialty.hvac_technician": "HVAC-technicus",
"lookup.contractor_specialty.handyman": "Klusjesman",
"lookup.contractor_specialty.landscaper": "Hovenier",
"lookup.contractor_specialty.roofer": "Dakdekker",
@@ -185,7 +160,88 @@
"lookup.contractor_specialty.carpenter": "Timmerman",
"lookup.contractor_specialty.pest_control": "Ongediertebestrijding",
"lookup.contractor_specialty.cleaning": "Schoonmaak",
"lookup.contractor_specialty.pool_service": "Zwembadonderhoud",
"lookup.contractor_specialty.general_contractor": "Algemeen Aannemer",
"lookup.contractor_specialty.other": "Anders"
"lookup.contractor_specialty.pool_service": "Zwembadservice",
"lookup.contractor_specialty.general_contractor": "Hoofdaannemer",
"lookup.contractor_specialty.other": "Anders",
"suggestion.reason.has_pool": "Je woning heeft een zwembad",
"suggestion.reason.has_sprinkler_system": "Je woning heeft een sproei-installatie",
"suggestion.reason.has_septic": "Je woning heeft een septische tank",
"suggestion.reason.has_fireplace": "Je woning heeft een open haard",
"suggestion.reason.has_garage": "Je woning heeft een garage",
"suggestion.reason.has_basement": "Je woning heeft een kelder",
"suggestion.reason.has_attic": "Je woning heeft een zolder",
"suggestion.reason.heating_type": "Past bij je verwarmingssysteem",
"suggestion.reason.cooling_type": "Past bij je koelsysteem",
"suggestion.reason.water_heater_type": "Past bij je boiler",
"suggestion.reason.roof_type": "Past bij je dak",
"suggestion.reason.exterior_type": "Past bij je gevel",
"suggestion.reason.flooring_primary": "Past bij je vloer",
"suggestion.reason.landscaping_type": "Past bij je tuin",
"suggestion.reason.property_type": "Aanbevolen voor je type woning",
"suggestion.reason.climate_region": "Aanbevolen voor je klimaat",
"lookup.residence_type.duplex": "Twee-onder-een-kap",
"lookup.residence_type.vacation_home": "Vakantiehuis",
"lookup.task_category.general": "Algemeen",
"lookup.task_frequency.bi_weekly": "Tweewekelijks",
"lookup.task_frequency.semi_annually": "Halfjaarlijks",
"lookup.task_frequency.custom": "Aangepast",
"lookup.contractor_specialty.appliance_repair": "Apparaatreparatie",
"lookup.contractor_specialty.cleaner": "Schoonmaker",
"lookup.contractor_specialty.locksmith": "Slotenmaker",
"lookup.home_profile.gas_furnace": "Gasketel",
"lookup.home_profile.electric_furnace": "Elektrische ketel",
"lookup.home_profile.heat_pump": "Warmtepomp",
"lookup.home_profile.boiler": "CV-ketel",
"lookup.home_profile.radiant": "Stralingsverwarming",
"lookup.home_profile.other": "Overig",
"lookup.home_profile.central_ac": "Centrale airco",
"lookup.home_profile.window_ac": "Raamairco",
"lookup.home_profile.evaporative": "Verdampings",
"lookup.home_profile.none": "Geen",
"lookup.home_profile.tank_gas": "Boiler (gas)",
"lookup.home_profile.tank_electric": "Boiler (elektrisch)",
"lookup.home_profile.tankless_gas": "Doorstroom (gas)",
"lookup.home_profile.tankless_electric": "Doorstroom (elektrisch)",
"lookup.home_profile.solar": "Zonne-energie",
"lookup.home_profile.asphalt_shingle": "Asfaltshingle",
"lookup.home_profile.metal": "Metaal",
"lookup.home_profile.tile": "Dakpan",
"lookup.home_profile.slate": "Leisteen",
"lookup.home_profile.wood_shake": "Houten shingle",
"lookup.home_profile.flat": "Plat",
"lookup.home_profile.brick": "Baksteen",
"lookup.home_profile.vinyl_siding": "Vinyl gevelbekleding",
"lookup.home_profile.wood_siding": "Houten gevelbekleding",
"lookup.home_profile.stucco": "Stucwerk",
"lookup.home_profile.stone": "Steen",
"lookup.home_profile.fiber_cement": "Vezelcement",
"lookup.home_profile.hardwood": "Hardhout",
"lookup.home_profile.laminate": "Laminaat",
"lookup.home_profile.carpet": "Tapijt",
"lookup.home_profile.vinyl": "Vinyl",
"lookup.home_profile.concrete": "Beton",
"lookup.home_profile.lawn": "Gazon",
"lookup.home_profile.desert": "Woestijn",
"lookup.home_profile.xeriscape": "Xeriscaping",
"lookup.home_profile.garden": "Tuin",
"lookup.home_profile.mixed": "Gemengd",
"lookup.document_type.warranty": "Garantie",
"lookup.document_type.manual": "Handleiding",
"lookup.document_type.receipt": "Bon/Factuur",
"lookup.document_type.inspection": "Inspectierapport",
"lookup.document_type.permit": "Vergunning",
"lookup.document_type.deed": "Akte/Eigendomsbewijs",
"lookup.document_type.insurance": "Verzekering",
"lookup.document_type.contract": "Contract",
"lookup.document_type.photo": "Foto",
"lookup.document_type.other": "Overig",
"lookup.document_category.appliance": "Apparaat",
"lookup.document_category.hvac": "HVAC",
"lookup.document_category.plumbing": "Loodgieterswerk",
"lookup.document_category.electrical": "Elektrisch",
"lookup.document_category.roofing": "Dak",
"lookup.document_category.structural": "Constructie",
"lookup.document_category.landscaping": "Tuinaanleg",
"lookup.document_category.general": "Algemeen",
"lookup.document_category.other": "Overig"
}
+97 -41
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "O login com Google nao esta configurado",
"error.google_signin_failed": "Falha no login com Google",
"error.invalid_google_token": "Token de identidade Google invalido",
"error.invalid_task_id": "ID da tarefa invalido",
"error.invalid_residence_id": "ID da propriedade invalido",
"error.invalid_contractor_id": "ID do prestador invalido",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "ID do usuario invalido",
"error.invalid_notification_id": "ID da notificacao invalido",
"error.invalid_device_id": "ID do dispositivo invalido",
"error.task_not_found": "Tarefa nao encontrada",
"error.residence_not_found": "Propriedade nao encontrada",
"error.contractor_not_found": "Prestador nao encontrado",
@@ -43,7 +41,6 @@
"error.user_not_found": "Usuario nao encontrado",
"error.share_code_invalid": "Codigo de compartilhamento invalido",
"error.share_code_expired": "O codigo de compartilhamento expirou",
"error.task_access_denied": "Voce nao tem acesso a esta tarefa",
"error.residence_access_denied": "Voce nao tem acesso a esta propriedade",
"error.contractor_access_denied": "Voce nao tem acesso a este prestador",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "Nao e possivel remover o proprietario",
"error.user_already_member": "O usuario ja e membro desta propriedade",
"error.properties_limit_reached": "Voce atingiu o numero maximo de propriedades para sua assinatura",
"error.task_already_cancelled": "A tarefa ja esta cancelada",
"error.task_already_archived": "A tarefa ja esta arquivada",
"error.failed_to_parse_form": "Falha ao analisar o formulario",
"error.task_id_required": "task_id e obrigatorio",
"error.invalid_task_id_value": "task_id invalido",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "residence_id invalido",
"error.title_required": "Titulo e obrigatorio",
"error.failed_to_upload_file": "Falha ao enviar arquivo",
"message.logged_out": "Logout realizado com sucesso",
"message.email_verified": "Email verificado com sucesso",
"message.verification_email_sent": "Email de verificacao enviado",
"message.password_reset_email_sent": "Se existir uma conta com este email, um codigo de redefinicao foi enviado.",
"message.reset_code_verified": "Codigo verificado com sucesso",
"message.password_reset_success": "Senha redefinida com sucesso. Por favor, faca login com sua nova senha.",
"message.task_deleted": "Tarefa excluida com sucesso",
"message.task_in_progress": "Tarefa marcada como em andamento",
"message.task_cancelled": "Tarefa cancelada",
@@ -79,46 +72,35 @@
"message.task_archived": "Tarefa arquivada",
"message.task_unarchived": "Tarefa desarquivada",
"message.completion_deleted": "Conclusao excluida com sucesso",
"message.residence_deleted": "Propriedade excluida com sucesso",
"message.user_removed": "Usuario removido da propriedade",
"message.tasks_report_generated": "Relatorio de tarefas gerado com sucesso",
"message.tasks_report_sent": "Relatorio de tarefas gerado e enviado para {{.Email}}",
"message.tasks_report_email_failed": "Relatorio de tarefas gerado mas o email nao pode ser enviado",
"message.contractor_deleted": "Prestador excluido com sucesso",
"message.document_deleted": "Documento excluido com sucesso",
"message.document_activated": "Documento ativado",
"message.document_deactivated": "Documento desativado",
"message.notification_marked_read": "Notificação marcada como lida",
"message.all_notifications_marked_read": "Todas as notificações marcadas como lidas",
"message.device_removed": "Dispositivo removido",
"message.subscription_upgraded": "Assinatura atualizada com sucesso",
"message.subscription_cancelled": "Assinatura cancelada. Você manterá os benefícios Pro até o final do seu período de faturamento.",
"message.subscription_restored": "Assinatura restaurada com sucesso",
"message.file_deleted": "Arquivo excluído com sucesso",
"message.static_data_refreshed": "Dados estáticos atualizados",
"error.notification_not_found": "Notificação não encontrada",
"error.invalid_platform": "Plataforma inválida",
"error.upgrade_trigger_not_found": "Gatilho de atualização não encontrado",
"error.receipt_data_required": "receipt_data é obrigatório para iOS",
"error.purchase_token_required": "purchase_token é obrigatório para Android",
"error.no_file_provided": "Nenhum arquivo fornecido",
"error.failed_to_fetch_residence_types": "Falha ao buscar tipos de propriedade",
"error.failed_to_fetch_task_categories": "Falha ao buscar categorias de tarefas",
"error.failed_to_fetch_task_priorities": "Falha ao buscar prioridades de tarefas",
"error.failed_to_fetch_task_frequencies": "Falha ao buscar frequências de tarefas",
"error.failed_to_fetch_task_statuses": "Falha ao buscar status de tarefas",
"error.failed_to_fetch_contractor_specialties": "Falha ao buscar especialidades de prestadores",
"push.task_due_soon.title": "Tarefa Proxima do Vencimento",
"push.task_due_soon.body": "{{.TaskTitle}} vence em {{.DueDate}}",
"push.task_overdue.title": "Tarefa Atrasada",
@@ -129,63 +111,137 @@
"push.task_assigned.body": "{{.TaskTitle}} foi atribuida a voce",
"push.residence_shared.title": "Propriedade Compartilhada",
"push.residence_shared.body": "{{.UserName}} compartilhou {{.ResidenceName}} com voce",
"email.welcome.subject": "Bem-vindo ao honeyDue!",
"email.verification.subject": "Verifique Seu Email",
"email.password_reset.subject": "Codigo de Redefinicao de Senha",
"email.tasks_report.subject": "Relatorio de Tarefas para {{.ResidenceName}}",
"lookup.residence_type.house": "Casa",
"lookup.residence_type.apartment": "Apartamento",
"lookup.residence_type.condo": "Condominio",
"lookup.residence_type.condo": "Condomínio",
"lookup.residence_type.townhouse": "Sobrado",
"lookup.residence_type.mobile_home": "Casa Movel",
"lookup.residence_type.mobile_home": "Casa vel",
"lookup.residence_type.other": "Outro",
"lookup.task_category.plumbing": "Encanamento",
"lookup.task_category.electrical": "Eletrica",
"lookup.task_category.hvac": "Climatizacao",
"lookup.task_category.appliances": "Eletrodomesticos",
"lookup.task_category.electrical": "Elétrica",
"lookup.task_category.hvac": "AVAC",
"lookup.task_category.appliances": "Eletrodomésticos",
"lookup.task_category.exterior": "Exterior",
"lookup.task_category.interior": "Interior",
"lookup.task_category.landscaping": "Paisagismo",
"lookup.task_category.safety": "Seguranca",
"lookup.task_category.safety": "Segurança",
"lookup.task_category.cleaning": "Limpeza",
"lookup.task_category.pest_control": "Controle de Pragas",
"lookup.task_category.pest_control": "Controle de pragas",
"lookup.task_category.seasonal": "Sazonal",
"lookup.task_category.other": "Outro",
"lookup.task_priority.low": "Baixa",
"lookup.task_priority.medium": "Media",
"lookup.task_priority.medium": "Média",
"lookup.task_priority.high": "Alta",
"lookup.task_priority.urgent": "Urgente",
"lookup.task_status.pending": "Pendente",
"lookup.task_status.in_progress": "Em Andamento",
"lookup.task_status.completed": "Concluida",
"lookup.task_status.cancelled": "Cancelada",
"lookup.task_status.archived": "Arquivada",
"lookup.task_frequency.once": "Uma Vez",
"lookup.task_frequency.daily": "Diario",
"lookup.task_frequency.once": "Uma vez",
"lookup.task_frequency.daily": "Diário",
"lookup.task_frequency.weekly": "Semanal",
"lookup.task_frequency.biweekly": "Quinzenal",
"lookup.task_frequency.monthly": "Mensal",
"lookup.task_frequency.quarterly": "Trimestral",
"lookup.task_frequency.semiannually": "Semestral",
"lookup.task_frequency.annually": "Anual",
"lookup.contractor_specialty.plumber": "Encanador",
"lookup.contractor_specialty.electrician": "Eletricista",
"lookup.contractor_specialty.hvac_technician": "Tecnico de Climatizacao",
"lookup.contractor_specialty.hvac_technician": "Técnico de AVAC",
"lookup.contractor_specialty.handyman": "Faz-tudo",
"lookup.contractor_specialty.landscaper": "Paisagista",
"lookup.contractor_specialty.landscaper": "Jardineiro",
"lookup.contractor_specialty.roofer": "Telhadista",
"lookup.contractor_specialty.painter": "Pintor",
"lookup.contractor_specialty.carpenter": "Carpinteiro",
"lookup.contractor_specialty.pest_control": "Controle de Pragas",
"lookup.contractor_specialty.pest_control": "Controle de pragas",
"lookup.contractor_specialty.cleaning": "Limpeza",
"lookup.contractor_specialty.pool_service": "Servico de Piscina",
"lookup.contractor_specialty.general_contractor": "Empreiteiro Geral",
"lookup.contractor_specialty.other": "Outro"
"lookup.contractor_specialty.pool_service": "Serviço de piscina",
"lookup.contractor_specialty.general_contractor": "Empreiteiro geral",
"lookup.contractor_specialty.other": "Outro",
"suggestion.reason.has_pool": "Sua casa tem piscina",
"suggestion.reason.has_sprinkler_system": "Sua casa tem sistema de irrigação",
"suggestion.reason.has_septic": "Sua casa tem fossa séptica",
"suggestion.reason.has_fireplace": "Sua casa tem lareira",
"suggestion.reason.has_garage": "Sua casa tem garagem",
"suggestion.reason.has_basement": "Sua casa tem porão",
"suggestion.reason.has_attic": "Sua casa tem sótão",
"suggestion.reason.heating_type": "Combina com seu sistema de aquecimento",
"suggestion.reason.cooling_type": "Combina com seu sistema de refrigeração",
"suggestion.reason.water_heater_type": "Combina com seu aquecedor de água",
"suggestion.reason.roof_type": "Combina com seu telhado",
"suggestion.reason.exterior_type": "Combina com seu exterior",
"suggestion.reason.flooring_primary": "Combina com seu piso",
"suggestion.reason.landscaping_type": "Combina com seu paisagismo",
"suggestion.reason.property_type": "Recomendado para seu tipo de imóvel",
"suggestion.reason.climate_region": "Recomendado para seu clima",
"lookup.residence_type.duplex": "Duplex",
"lookup.residence_type.vacation_home": "Casa de férias",
"lookup.task_category.general": "Geral",
"lookup.task_frequency.bi_weekly": "Quinzenal",
"lookup.task_frequency.semi_annually": "Semestral",
"lookup.task_frequency.custom": "Personalizado",
"lookup.contractor_specialty.appliance_repair": "Reparo de eletrodomésticos",
"lookup.contractor_specialty.cleaner": "Faxineiro",
"lookup.contractor_specialty.locksmith": "Chaveiro",
"lookup.home_profile.gas_furnace": "Aquecedor a gás",
"lookup.home_profile.electric_furnace": "Aquecedor elétrico",
"lookup.home_profile.heat_pump": "Bomba de calor",
"lookup.home_profile.boiler": "Caldeira",
"lookup.home_profile.radiant": "Radiante",
"lookup.home_profile.other": "Outro",
"lookup.home_profile.central_ac": "AC central",
"lookup.home_profile.window_ac": "AC de janela",
"lookup.home_profile.evaporative": "Evaporativo",
"lookup.home_profile.none": "Nenhum",
"lookup.home_profile.tank_gas": "Tanque (gás)",
"lookup.home_profile.tank_electric": "Tanque (elétrico)",
"lookup.home_profile.tankless_gas": "Sem tanque (gás)",
"lookup.home_profile.tankless_electric": "Sem tanque (elétrico)",
"lookup.home_profile.solar": "Solar",
"lookup.home_profile.asphalt_shingle": "Telha asfáltica",
"lookup.home_profile.metal": "Metal",
"lookup.home_profile.tile": "Telha",
"lookup.home_profile.slate": "Ardósia",
"lookup.home_profile.wood_shake": "Telha de madeira",
"lookup.home_profile.flat": "Plano",
"lookup.home_profile.brick": "Tijolo",
"lookup.home_profile.vinyl_siding": "Revestimento de vinil",
"lookup.home_profile.wood_siding": "Revestimento de madeira",
"lookup.home_profile.stucco": "Estuque",
"lookup.home_profile.stone": "Pedra",
"lookup.home_profile.fiber_cement": "Cimento reforçado",
"lookup.home_profile.hardwood": "Madeira de lei",
"lookup.home_profile.laminate": "Laminado",
"lookup.home_profile.carpet": "Carpete",
"lookup.home_profile.vinyl": "Vinil",
"lookup.home_profile.concrete": "Concreto",
"lookup.home_profile.lawn": "Gramado",
"lookup.home_profile.desert": "Deserto",
"lookup.home_profile.xeriscape": "Xeropaisagismo",
"lookup.home_profile.garden": "Jardim",
"lookup.home_profile.mixed": "Misto",
"lookup.document_type.warranty": "Garantia",
"lookup.document_type.manual": "Manual do usuário",
"lookup.document_type.receipt": "Recibo/Fatura",
"lookup.document_type.inspection": "Relatório de inspeção",
"lookup.document_type.permit": "Licença",
"lookup.document_type.deed": "Escritura/Título",
"lookup.document_type.insurance": "Seguro",
"lookup.document_type.contract": "Contrato",
"lookup.document_type.photo": "Foto",
"lookup.document_type.other": "Outro",
"lookup.document_category.appliance": "Eletrodoméstico",
"lookup.document_category.hvac": "AVAC",
"lookup.document_category.plumbing": "Encanamento",
"lookup.document_category.electrical": "Elétrica",
"lookup.document_category.roofing": "Telhado",
"lookup.document_category.structural": "Estrutural",
"lookup.document_category.landscaping": "Paisagismo",
"lookup.document_category.general": "Geral",
"lookup.document_category.other": "Outro"
}
+85 -29
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "未配置 Google 登录",
"error.google_signin_failed": "Google 登录失败",
"error.invalid_google_token": "Google 身份令牌无效",
"error.invalid_task_id": "任务 ID 无效",
"error.invalid_residence_id": "房产 ID 无效",
"error.invalid_contractor_id": "承包商 ID 无效",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "用户 ID 无效",
"error.invalid_notification_id": "通知 ID 无效",
"error.invalid_device_id": "设备 ID 无效",
"error.task_not_found": "未找到任务",
"error.residence_not_found": "未找到房产",
"error.contractor_not_found": "未找到承包商",
@@ -43,7 +41,6 @@
"error.user_not_found": "未找到用户",
"error.share_code_invalid": "分享码无效",
"error.share_code_expired": "分享码已过期",
"error.task_access_denied": "您无权访问此任务",
"error.residence_access_denied": "您无权访问此房产",
"error.contractor_access_denied": "您无权访问此承包商",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "无法移除房产所有者",
"error.user_already_member": "用户已是此房产的成员",
"error.properties_limit_reached": "您已达到订阅计划的房产数量上限",
"error.task_already_cancelled": "任务已取消",
"error.task_already_archived": "任务已归档",
"error.failed_to_parse_form": "解析多部分表单失败",
"error.task_id_required": "需要 task_id",
"error.invalid_task_id_value": "task_id 无效",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "residence_id 无效",
"error.title_required": "需要标题",
"error.failed_to_upload_file": "上传文件失败",
"message.logged_out": "已成功退出",
"message.email_verified": "邮箱验证成功",
"message.verification_email_sent": "验证邮件已发送",
"message.password_reset_email_sent": "如果该邮箱存在账户,密码重置验证码已发送。",
"message.reset_code_verified": "验证码验证成功",
"message.password_reset_success": "密码重置成功,请使用新密码登录。",
"message.task_deleted": "任务删除成功",
"message.task_in_progress": "任务已标记为进行中",
"message.task_cancelled": "任务已取消",
@@ -79,46 +72,35 @@
"message.task_archived": "任务已归档",
"message.task_unarchived": "任务已取消归档",
"message.completion_deleted": "完成记录删除成功",
"message.residence_deleted": "房产删除成功",
"message.user_removed": "用户已从房产中移除",
"message.tasks_report_generated": "任务报告生成成功",
"message.tasks_report_sent": "任务报告已生成并发送至 {{.Email}}",
"message.tasks_report_email_failed": "任务报告已生成但无法发送邮件",
"message.contractor_deleted": "承包商删除成功",
"message.document_deleted": "文档删除成功",
"message.document_activated": "文档已激活",
"message.document_deactivated": "文档已停用",
"message.notification_marked_read": "通知已标记为已读",
"message.all_notifications_marked_read": "所有通知已标记为已读",
"message.device_removed": "设备已移除",
"message.subscription_upgraded": "订阅升级成功",
"message.subscription_cancelled": "订阅已取消。您将保留专业版权益至当前账单周期结束。",
"message.subscription_restored": "订阅恢复成功",
"message.file_deleted": "文件删除成功",
"message.static_data_refreshed": "静态数据已刷新",
"error.notification_not_found": "未找到通知",
"error.invalid_platform": "平台无效",
"error.upgrade_trigger_not_found": "未找到升级触发器",
"error.receipt_data_required": "iOS 需要 receipt_data",
"error.purchase_token_required": "Android 需要 purchase_token",
"error.no_file_provided": "未提供文件",
"error.failed_to_fetch_residence_types": "获取房产类型失败",
"error.failed_to_fetch_task_categories": "获取任务分类失败",
"error.failed_to_fetch_task_priorities": "获取任务优先级失败",
"error.failed_to_fetch_task_frequencies": "获取任务频率失败",
"error.failed_to_fetch_task_statuses": "获取任务状态失败",
"error.failed_to_fetch_contractor_specialties": "获取承包商专业类别失败",
"push.task_due_soon.title": "任务即将到期",
"push.task_due_soon.body": "{{.TaskTitle}} 将于 {{.DueDate}} 到期",
"push.task_overdue.title": "任务已逾期",
@@ -129,19 +111,16 @@
"push.task_assigned.body": "您已被分配到 {{.TaskTitle}}",
"push.residence_shared.title": "房产已分享",
"push.residence_shared.body": "{{.UserName}} 与您分享了 {{.ResidenceName}}",
"email.welcome.subject": "欢迎使用 honeyDue",
"email.verification.subject": "验证您的邮箱",
"email.password_reset.subject": "密码重置验证码",
"email.tasks_report.subject": "{{.ResidenceName}} 的任务报告",
"lookup.residence_type.house": "独立屋",
"lookup.residence_type.house": "独栋房屋",
"lookup.residence_type.apartment": "公寓",
"lookup.residence_type.condo": "共管公寓",
"lookup.residence_type.townhouse": "联排别墅",
"lookup.residence_type.mobile_home": "移动房屋",
"lookup.residence_type.other": "其他",
"lookup.task_category.plumbing": "管道",
"lookup.task_category.electrical": "电气",
"lookup.task_category.hvac": "暖通空调",
@@ -154,18 +133,15 @@
"lookup.task_category.pest_control": "害虫防治",
"lookup.task_category.seasonal": "季节性",
"lookup.task_category.other": "其他",
"lookup.task_priority.low": "低",
"lookup.task_priority.medium": "中",
"lookup.task_priority.high": "高",
"lookup.task_priority.urgent": "紧急",
"lookup.task_status.pending": "待处理",
"lookup.task_status.in_progress": "进行中",
"lookup.task_status.completed": "已完成",
"lookup.task_status.cancelled": "已取消",
"lookup.task_status.archived": "已归档",
"lookup.task_frequency.once": "一次",
"lookup.task_frequency.daily": "每天",
"lookup.task_frequency.weekly": "每周",
@@ -174,12 +150,11 @@
"lookup.task_frequency.quarterly": "每季度",
"lookup.task_frequency.semiannually": "每半年",
"lookup.task_frequency.annually": "每年",
"lookup.contractor_specialty.plumber": "水管工",
"lookup.contractor_specialty.plumber": "管道工",
"lookup.contractor_specialty.electrician": "电工",
"lookup.contractor_specialty.hvac_technician": "暖通空调技师",
"lookup.contractor_specialty.handyman": "杂工",
"lookup.contractor_specialty.landscaper": "园林工",
"lookup.contractor_specialty.landscaper": "园艺师",
"lookup.contractor_specialty.roofer": "屋顶工",
"lookup.contractor_specialty.painter": "油漆工",
"lookup.contractor_specialty.carpenter": "木工",
@@ -187,5 +162,86 @@
"lookup.contractor_specialty.cleaning": "清洁",
"lookup.contractor_specialty.pool_service": "泳池服务",
"lookup.contractor_specialty.general_contractor": "总承包商",
"lookup.contractor_specialty.other": "其他"
"lookup.contractor_specialty.other": "其他",
"suggestion.reason.has_pool": "您的住宅有游泳池",
"suggestion.reason.has_sprinkler_system": "您的住宅有喷灌系统",
"suggestion.reason.has_septic": "您的住宅有化粪池",
"suggestion.reason.has_fireplace": "您的住宅有壁炉",
"suggestion.reason.has_garage": "您的住宅有车库",
"suggestion.reason.has_basement": "您的住宅有地下室",
"suggestion.reason.has_attic": "您的住宅有阁楼",
"suggestion.reason.heating_type": "与您的供暖系统匹配",
"suggestion.reason.cooling_type": "与您的制冷系统匹配",
"suggestion.reason.water_heater_type": "与您的热水器匹配",
"suggestion.reason.roof_type": "与您的屋顶匹配",
"suggestion.reason.exterior_type": "与您的外墙匹配",
"suggestion.reason.flooring_primary": "与您的地板匹配",
"suggestion.reason.landscaping_type": "与您的庭院匹配",
"suggestion.reason.property_type": "为您的房产类型推荐",
"suggestion.reason.climate_region": "为您所在气候推荐",
"lookup.residence_type.duplex": "双拼住宅",
"lookup.residence_type.vacation_home": "度假屋",
"lookup.task_category.general": "通用",
"lookup.task_frequency.bi_weekly": "每两周",
"lookup.task_frequency.semi_annually": "每半年",
"lookup.task_frequency.custom": "自定义",
"lookup.contractor_specialty.appliance_repair": "家电维修",
"lookup.contractor_specialty.cleaner": "清洁工",
"lookup.contractor_specialty.locksmith": "锁匠",
"lookup.home_profile.gas_furnace": "燃气炉",
"lookup.home_profile.electric_furnace": "电炉",
"lookup.home_profile.heat_pump": "热泵",
"lookup.home_profile.boiler": "锅炉",
"lookup.home_profile.radiant": "辐射式",
"lookup.home_profile.other": "其他",
"lookup.home_profile.central_ac": "中央空调",
"lookup.home_profile.window_ac": "窗式空调",
"lookup.home_profile.evaporative": "蒸发式",
"lookup.home_profile.none": "无",
"lookup.home_profile.tank_gas": "储水式(燃气)",
"lookup.home_profile.tank_electric": "储水式(电)",
"lookup.home_profile.tankless_gas": "即热式(燃气)",
"lookup.home_profile.tankless_electric": "即热式(电)",
"lookup.home_profile.solar": "太阳能",
"lookup.home_profile.asphalt_shingle": "沥青瓦",
"lookup.home_profile.metal": "金属",
"lookup.home_profile.tile": "瓦片",
"lookup.home_profile.slate": "石板",
"lookup.home_profile.wood_shake": "木瓦",
"lookup.home_profile.flat": "平顶",
"lookup.home_profile.brick": "砖",
"lookup.home_profile.vinyl_siding": "乙烯基壁板",
"lookup.home_profile.wood_siding": "木壁板",
"lookup.home_profile.stucco": "灰泥",
"lookup.home_profile.stone": "石材",
"lookup.home_profile.fiber_cement": "纤维水泥",
"lookup.home_profile.hardwood": "硬木",
"lookup.home_profile.laminate": "复合地板",
"lookup.home_profile.carpet": "地毯",
"lookup.home_profile.vinyl": "乙烯基",
"lookup.home_profile.concrete": "混凝土",
"lookup.home_profile.lawn": "草坪",
"lookup.home_profile.desert": "沙漠",
"lookup.home_profile.xeriscape": "旱生园艺",
"lookup.home_profile.garden": "花园",
"lookup.home_profile.mixed": "混合",
"lookup.document_type.warranty": "保修",
"lookup.document_type.manual": "用户手册",
"lookup.document_type.receipt": "收据/发票",
"lookup.document_type.inspection": "检查报告",
"lookup.document_type.permit": "许可证",
"lookup.document_type.deed": "契据/产权",
"lookup.document_type.insurance": "保险",
"lookup.document_type.contract": "合同",
"lookup.document_type.photo": "照片",
"lookup.document_type.other": "其他",
"lookup.document_category.appliance": "家电",
"lookup.document_category.hvac": "暖通空调",
"lookup.document_category.plumbing": "管道",
"lookup.document_category.electrical": "电气",
"lookup.document_category.roofing": "屋顶",
"lookup.document_category.structural": "结构",
"lookup.document_category.landscaping": "园艺",
"lookup.document_category.general": "通用",
"lookup.document_category.other": "其他"
}
@@ -171,6 +171,11 @@ func (app *SubscriptionTestApp) makeAuthenticatedRequest(t *testing.T, method, p
// TestIntegration_IsFreeBypassesLimitations tests that users with IsFree=true
// see limitations_enabled=false regardless of global settings
func TestIntegration_IsFreeBypassesLimitations(t *testing.T) {
// TEMPORARILY DISABLED — Subscriptions: GetSubscriptionStatus now returns
// a limitations_enabled=false stub for everyone, so the assertion that a
// normal user sees limitations_enabled=true (per EnableLimitations setting)
// no longer holds. Remove this skip when GetSubscriptionStatus is restored.
t.Skip("subscription feature disabled — see subscription_service.go TEMPORARILY DISABLED")
app := setupSubscriptionTest(t)
// Register and login a user
@@ -289,6 +294,10 @@ func TestIntegration_IsFreeBypassesCheckLimit(t *testing.T) {
// TestIntegration_IsFreeIndependentOfTier tests that IsFree works regardless of
// the user's subscription tier
func TestIntegration_IsFreeIndependentOfTier(t *testing.T) {
// TEMPORARILY DISABLED — Subscriptions: GetSubscriptionStatus is stubbed,
// so the Pro+!IsFree case (which would normally return limitations_enabled=true)
// is no longer reachable. Remove this skip when the feature is restored.
t.Skip("subscription feature disabled — see subscription_service.go TEMPORARILY DISABLED")
app := setupSubscriptionTest(t)
// Register and login a user
+100
View File
@@ -5,10 +5,12 @@
package kratos
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
@@ -19,6 +21,16 @@ import (
// or inactive — the caller should respond 401.
var ErrUnauthorized = errors.New("kratos: session invalid or inactive")
// ErrIdentityExists is returned by CreateIdentity when an identity with the
// same credential identifier (email) already exists — caller should respond 409.
var ErrIdentityExists = errors.New("kratos: identity already exists")
// ErrInvalidCredentials is returned by CreateIdentity when Kratos rejects the
// password against its policy (too short, breached, etc.) — caller responds 400.
type ErrInvalidCredentials struct{ Reason string }
func (e *ErrInvalidCredentials) Error() string { return "kratos: " + e.Reason }
// Client talks to the Ory Kratos public and admin APIs.
type Client struct {
publicURL string
@@ -112,6 +124,94 @@ func (c *Client) Whoami(ctx context.Context, sessionToken, cookie string) (*Sess
return &s, nil
}
// CreateIdentity admin-creates a Kratos identity with a password credential and
// an UNVERIFIED email, returning the created identity (with its UUID).
//
// Why admin-create instead of the self-service registration flow: self-service
// registration always auto-emails a verification code to a flow whose id Kratos
// never returns to the client (verified 2026-06-03 — true with and without the
// session after-hook), so the client can never submit the user's code to the
// right flow. Admin creation runs no registration hooks and sends no email, so
// honeyDue drives verification explicitly afterward: the client starts its own
// verification flow (the single code) and the user enters it. The new identity
// is fully usable immediately — the client logs in right after to get a session
// (Kratos permits login for unverified identities; app access is gated on the
// verified flag, not on Kratos login).
//
// password is sent in cleartext over the in-cluster admin API (never exposed
// publicly); Kratos hashes it with the configured bcrypt cost and applies the
// password policy, returning 400 on a weak/breached password and 409 on a
// duplicate email.
func (c *Client) CreateIdentity(ctx context.Context, email, firstName, lastName, password string) (*Identity, error) {
if c.adminURL == "" {
return nil, errors.New("kratos: admin URL not configured")
}
payload := map[string]any{
"schema_id": "honeydue",
"traits": map[string]any{
"email": email,
"name": map[string]any{"first": firstName, "last": lastName},
},
"credentials": map[string]any{
"password": map[string]any{
"config": map[string]any{"password": password},
},
},
}
buf, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.adminURL+"/admin/identities", bytes.NewReader(buf))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("kratos create identity: %w", err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated, http.StatusOK:
var id Identity
if err := json.NewDecoder(resp.Body).Decode(&id); err != nil {
return nil, fmt.Errorf("kratos create identity: decode: %w", err)
}
return &id, nil
case http.StatusConflict:
return nil, ErrIdentityExists
case http.StatusBadRequest:
body, _ := io.ReadAll(resp.Body)
return nil, &ErrInvalidCredentials{Reason: kratosReason(body)}
default:
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("kratos create identity: unexpected status %d: %s", resp.StatusCode, string(body))
}
}
// kratosReason pulls a human-readable reason out of a Kratos error body
// ({"error":{"reason":"...","message":"..."}}), falling back to the raw body.
func kratosReason(body []byte) string {
var env struct {
Error struct {
Reason string `json:"reason"`
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(body, &env) == nil {
if env.Error.Reason != "" {
return env.Error.Reason
}
if env.Error.Message != "" {
return env.Error.Message
}
}
return strings.TrimSpace(string(body))
}
// DeleteIdentity permanently removes a Kratos identity by its UUID via the
// admin API (DELETE /admin/identities/{id}). A 404 is treated as success —
// the identity is already gone, which is the desired end state, so the call
+74
View File
@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/models"
)
@@ -117,3 +118,76 @@ func TestAdminAuth_QueryParamToken_Rejected(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, rec.Code, "query param token must be rejected")
assert.Contains(t, rec.Body.String(), "Authorization required")
}
// requireVerifiedContext builds an Echo context primed as the Authenticate
// middleware would leave it: an auth_user and the verified flag.
func requireVerifiedContext(user *models.User, verified bool) (echo.Context, *httptest.ResponseRecorder) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/residences/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if user != nil {
c.Set(AuthUserKey, user)
}
c.Set(AuthVerifiedKey, verified)
return c, rec
}
// TestRequireVerified_VerifiedUser_Passes confirms a verified user reaches the
// wrapped handler. This is the default tier for all app-data routes now that
// RequireVerified is applied at the `verified` group level in the router.
func TestRequireVerified_VerifiedUser_Passes(t *testing.T) {
m := &KratosAuth{}
c, _ := requireVerifiedContext(&models.User{Username: "v"}, true)
reached := false
handler := m.RequireVerified()(func(c echo.Context) error {
reached = true
return c.NoContent(http.StatusOK)
})
err := handler(c)
assert.NoError(t, err)
assert.True(t, reached, "verified user should reach the handler")
}
// TestRequireVerified_UnverifiedUser_403 is the core gating assertion for the
// new policy: an authenticated-but-unverified user is rejected with 403 on a
// data route, NOT allowed through.
func TestRequireVerified_UnverifiedUser_403(t *testing.T) {
m := &KratosAuth{}
c, _ := requireVerifiedContext(&models.User{Username: "u"}, false)
reached := false
handler := m.RequireVerified()(func(c echo.Context) error {
reached = true
return c.NoContent(http.StatusOK)
})
err := handler(c)
require.Error(t, err)
assert.False(t, reached, "unverified user must NOT reach the handler")
var appErr *apperrors.AppError
require.ErrorAs(t, err, &appErr)
assert.Equal(t, http.StatusForbidden, appErr.Code)
}
// TestRequireVerified_NoUser_401 confirms RequireVerified rejects an
// unauthenticated request with 401 (defense-in-depth even though Authenticate
// runs first in the router).
func TestRequireVerified_NoUser_401(t *testing.T) {
m := &KratosAuth{}
c, _ := requireVerifiedContext(nil, false)
handler := m.RequireVerified()(func(c echo.Context) error {
return c.NoContent(http.StatusOK)
})
err := handler(c)
require.Error(t, err)
var appErr *apperrors.AppError
require.ErrorAs(t, err, &appErr)
assert.Equal(t, http.StatusUnauthorized, appErr.Code)
}
+29 -8
View File
@@ -24,8 +24,10 @@ const (
AuthUserKey = "auth_user"
// AuthTokenKey stores the raw session credential in the echo context.
AuthTokenKey = "auth_token"
// authVerifiedKey stores the Kratos email-verified flag in the context.
authVerifiedKey = "auth_email_verified"
// AuthVerifiedKey stores the Kratos email-verified flag in the context.
// Handlers can read this to override stale local mirrors like
// user_profile.verified with the live Kratos truth.
AuthVerifiedKey = "auth_email_verified"
// UserCacheTTL / UserCacheMaxSize bound the in-memory local-user cache.
UserCacheTTL = 5 * time.Minute
@@ -33,7 +35,14 @@ const (
// kratosSessionCacheTTL is how long a validated session is cached in
// Redis, so most authed requests skip the Kratos /whoami round trip.
kratosSessionCacheTTL = 5 * time.Minute
//
// PRODUCTION CAVEAT (2026-06-03): until Kratos is deployed in-cluster,
// the Whoami fallback ALWAYS fails (no kratos Service). That means every
// cache miss = 401 = forced re-login. We mitigate by (a) using a long
// TTL and (b) refreshing the TTL on every cache hit (see resolve()).
// This is a short-term workaround — restore to a few minutes once Kratos
// is live and the runbook §11 #7 prerequisites are done.
kratosSessionCacheTTL = 24 * time.Hour
kratosSessionPrefix = "kratos_sess:"
)
@@ -69,7 +78,7 @@ func (m *KratosAuth) Authenticate() echo.MiddlewareFunc {
}
c.Set(AuthUserKey, user)
c.Set(AuthTokenKey, cred)
c.Set(authVerifiedKey, verified)
c.Set(AuthVerifiedKey, verified)
return next(c)
}
}
@@ -83,7 +92,7 @@ func (m *KratosAuth) OptionalAuthenticate() echo.MiddlewareFunc {
if user, verified, cred, err := m.resolve(c); err == nil {
c.Set(AuthUserKey, user)
c.Set(AuthTokenKey, cred)
c.Set(authVerifiedKey, verified)
c.Set(AuthVerifiedKey, verified)
}
return next(c)
}
@@ -98,7 +107,7 @@ func (m *KratosAuth) RequireVerified() echo.MiddlewareFunc {
if GetAuthUser(c) == nil {
return apperrors.Unauthorized("error.not_authenticated")
}
if verified, _ := c.Get(authVerifiedKey).(bool); !verified {
if verified, _ := c.Get(AuthVerifiedKey).(bool); !verified {
return apperrors.Forbidden("error.email_not_verified")
}
return next(c)
@@ -122,8 +131,20 @@ func (m *KratosAuth) resolve(c echo.Context) (*models.User, bool, string, error)
cacheKey := kratosSessionPrefix + hashCredential(cred)
if m.cache != nil {
if v, err := m.cache.GetString(ctx, cacheKey); err == nil && v != "" {
if user, verified, ok := m.userFromCacheValue(ctx, v); ok {
return user, verified, cred, nil
// Only a cached `verified=true` is authoritative — email verification
// is sticky (it never reverts), so we can safely short-circuit.
// A cached `verified=false` is deliberately NOT trusted: the user may
// have verified their email since this entry was written, and a stale
// false would lock a just-verified user out of every verified-gated
// route until the 24h TTL expired (e.g. sign up -> verify -> create a
// residence immediately). On a cached false we fall through and
// re-resolve the live status from Kratos /whoami below.
if user, verified, ok := m.userFromCacheValue(ctx, v); ok && verified {
// Sliding-window refresh: extend the TTL on every successful hit
// so active (verified) users aren't bounced when their entry
// would otherwise expire. Best-effort.
_ = m.cache.SetString(ctx, cacheKey, v, kratosSessionCacheTTL)
return user, true, cred, nil
}
}
}
+21
View File
@@ -1,6 +1,7 @@
package prom
import (
"net/http"
"strconv"
"time"
@@ -54,6 +55,11 @@ var (
Help: "Duration of asynq background job execution in seconds.",
Buckets: []float64{0.01, 0.05, 0.1, 0.5, 1, 5, 10, 30, 60, 300},
}, []string{"task_type", "result"})
cacheOps = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "cache_ops_total",
Help: "Redis cache operations by type and result.",
}, []string{"operation", "result"}) // operation: get|set; result: hit|miss|ok|error
)
func init() {
@@ -67,6 +73,7 @@ func init() {
apnsSendDuration,
fcmSendDuration,
asynqJobDuration,
cacheOps,
)
}
@@ -77,6 +84,20 @@ func Handler() echo.HandlerFunc {
return echo.WrapHandler(h)
}
// HTTPHandler returns a net/http handler bound to the package Registry, for the
// worker's plain http.ServeMux (the api uses Handler() for Echo). This is what
// lets the worker's apns/fcm/asynq histograms actually get scraped — they were
// recorded all along but the worker exposed no /metrics endpoint.
func HTTPHandler() http.Handler {
return promhttp.HandlerFor(Registry, promhttp.HandlerOpts{Registry: Registry})
}
// ObserveCacheOp records a Redis cache operation. operation is "get" or "set";
// result is "hit"/"miss"/"error" for gets and "ok"/"error" for sets.
func ObserveCacheOp(operation, result string) {
cacheOps.WithLabelValues(operation, result).Inc()
}
// HTTPMiddleware records http_request_duration_seconds for every request,
// labeled by Echo route pattern, method, and status code.
func HTTPMiddleware() echo.MiddlewareFunc {
+10
View File
@@ -66,6 +66,16 @@ func (r *UserRepository) FindByID(id uint) (*models.User, error) {
return &user, nil
}
// MarkVerified sets user_userprofile.verified=true for the given user.
// Syncs the local mirror with Kratos's verifiable_addresses.verified after
// a successful verification flow. Idempotent — re-flipping an already-true
// row is a guarded no-op write.
func (r *UserRepository) MarkVerified(userID uint) error {
return r.db.Model(&models.UserProfile{}).
Where("user_id = ? AND verified = ?", userID, false).
Update("verified", true).Error
}
// FindByIDWithProfile finds a user by ID with profile preloaded
func (r *UserRepository) FindByIDWithProfile(id uint) (*models.User, error) {
var user models.User
+61 -24
View File
@@ -30,6 +30,7 @@ import (
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/services"
customvalidator "github.com/treytartt/honeydue-api/internal/validator"
"github.com/treytartt/honeydue-api/internal/worker"
"github.com/treytartt/honeydue-api/pkg/utils"
)
@@ -45,6 +46,11 @@ type Dependencies struct {
PushClient *push.Client // Direct APNs/FCM client
StorageService *services.StorageService
MonitoringService *monitoring.Service
// TaskEnqueuer is the Asynq client used to push background work onto the
// shared Redis queue. Optional — when nil, services that would enqueue
// (currently: task-completion notification fan-out) fall back to their
// inline implementation. Tests can omit it; production must wire it.
TaskEnqueuer worker.Enqueuer
}
// SetupRouter creates and configures the Echo router
@@ -215,6 +221,13 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
taskService.SetEmailService(deps.EmailService)
taskService.SetResidenceService(residenceService) // For including TotalSummary in CRUD responses
taskService.SetStorageService(deps.StorageService) // For reading completion images for email
if deps.TaskEnqueuer != nil {
// Offload completion notifications (push + email + B2 image fetches)
// to the Asynq worker so POST /api/task-completions/ doesn't pay for
// them in the response path. When the enqueuer is absent (tests),
// task_service falls back to the inline implementation.
taskService.SetTaskCompletedNotificationEnqueuer(deps.TaskEnqueuer)
}
subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo)
residenceService.SetSubscriptionService(subscriptionService) // Wire up subscription service for tier limit enforcement
@@ -257,6 +270,9 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
authHandler := handlers.NewAuthHandler(authService, deps.EmailService, deps.Cache)
authHandler.SetStorageService(deps.StorageService)
authHandler.SetAuditService(auditService)
if deps.TaskEnqueuer != nil {
authHandler.SetEnqueuer(deps.TaskEnqueuer)
}
userHandler := handlers.NewUserHandler(userService)
residenceHandler := handlers.NewResidenceHandler(residenceService, deps.PDFService, deps.EmailService, cfg.Features.PDFReportsEnabled)
taskHandler := handlers.NewTaskHandler(taskService, deps.StorageService)
@@ -315,8 +331,13 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
// API group
api := e.Group("/api")
{
// Session lifecycle (login, register, logout, password reset) is
// handled by Ory Kratos — no public auth routes in this service.
// Session lifecycle (login, logout, password reset, email verification)
// is handled directly by Ory Kratos from the client. Registration is the
// exception: it goes through this endpoint, which admin-creates the
// Kratos identity so no verification email is auto-sent to an
// unreachable flow (see handlers.AuthHandler.Register). Public — the
// caller has no session yet.
api.POST("/auth/register/", authHandler.Register)
// Public data routes (no auth required)
setupPublicDataRoutes(api, residenceHandler, taskHandler, contractorHandler, staticDataHandler, subscriptionHandler, taskTemplateHandler)
@@ -324,29 +345,44 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
// Subscription webhook routes (no auth - called by Apple/Google servers)
setupWebhookRoutes(api, subscriptionWebhookHandler)
// Protected routes (auth required)
// Authenticated routes (valid session required). This level is the
// sign-up / shell allow-list: an authenticated-but-UNVERIFIED user may
// call these (read their own user + verification status, complete their
// profile during sign-up). EVERYTHING else requires a verified email
// (see the `verified` sub-group below).
protected := api.Group("")
protected.Use(authMiddleware.Authenticate())
protected.Use(custommiddleware.TimezoneMiddleware())
{
// Allow-list — authenticated, may be unverified.
setupProtectedAuthRoutes(protected, authHandler)
setupResidenceRoutes(protected, residenceHandler, authMiddleware.RequireVerified())
setupTaskRoutes(protected, taskHandler)
setupSuggestionRoutes(protected, suggestionHandler)
setupContractorRoutes(protected, contractorHandler)
setupDocumentRoutes(protected, documentHandler)
setupNotificationRoutes(protected, notificationHandler)
setupSubscriptionRoutes(protected, subscriptionHandler)
setupUserRoutes(protected, userHandler)
// Upload routes (only if storage service is configured)
if uploadHandler != nil {
setupUploadRoutes(protected, uploadHandler)
}
// Verified routes (authenticated AND email-verified) — the DEFAULT
// for all app data and actions. RequireVerified is applied ONCE at
// the group level so verification is the default and every route
// added under here is gated automatically. (The previous per-route
// approach left ~70 routes unverified — see the LIVE auth audit.)
verified := protected.Group("")
verified.Use(authMiddleware.RequireVerified())
{
setupResidenceRoutes(verified, residenceHandler)
setupTaskRoutes(verified, taskHandler)
setupSuggestionRoutes(verified, suggestionHandler)
setupContractorRoutes(verified, contractorHandler)
setupDocumentRoutes(verified, documentHandler)
setupNotificationRoutes(verified, notificationHandler)
setupSubscriptionRoutes(verified, subscriptionHandler)
setupUserRoutes(verified, userHandler)
// Media routes (authenticated media serving)
if mediaHandler != nil {
setupMediaRoutes(protected, mediaHandler)
// Upload routes (only if storage service is configured)
if uploadHandler != nil {
setupUploadRoutes(verified, uploadHandler)
}
// Media routes (verified media serving)
if mediaHandler != nil {
setupMediaRoutes(verified, mediaHandler)
}
}
}
}
@@ -526,6 +562,7 @@ func setupProtectedAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler
auth.PUT("/profile/", authHandler.UpdateProfile)
auth.PATCH("/profile/", authHandler.UpdateProfile)
auth.DELETE("/account/", authHandler.DeleteAccount)
auth.POST("/export/", authHandler.ExportData)
}
}
@@ -565,7 +602,7 @@ func setupPublicDataRoutes(api *echo.Group, residenceHandler *handlers.Residence
}
// setupResidenceRoutes configures residence routes
func setupResidenceRoutes(api *echo.Group, residenceHandler *handlers.ResidenceHandler, requireVerified echo.MiddlewareFunc) {
func setupResidenceRoutes(api *echo.Group, residenceHandler *handlers.ResidenceHandler) {
residences := api.Group("/residences")
{
residences.GET("/", residenceHandler.ListResidences)
@@ -580,11 +617,11 @@ func setupResidenceRoutes(api *echo.Group, residenceHandler *handlers.ResidenceH
residences.DELETE("/:id/", residenceHandler.DeleteResidence)
residences.GET("/:id/share-code/", residenceHandler.GetShareCode)
// Audit LIVE-L19: generating a residence share code requires a
// verified email — it blocks bad-faith unverified signups from
// minting share codes.
residences.POST("/:id/generate-share-code/", residenceHandler.GenerateShareCode, requireVerified)
residences.POST("/:id/generate-share-package/", residenceHandler.GenerateSharePackage, requireVerified)
// Verification is now enforced at the group level for ALL residence
// routes (see the `verified` group in SetupRouter) — the previous
// per-route RequireVerified on just these two is no longer needed.
residences.POST("/:id/generate-share-code/", residenceHandler.GenerateShareCode)
residences.POST("/:id/generate-share-package/", residenceHandler.GenerateSharePackage)
residences.POST("/:id/generate-tasks-report/", residenceHandler.GenerateTasksReport)
residences.GET("/:id/users/", residenceHandler.GetResidenceUsers)
residences.DELETE("/:id/users/:user_id/", residenceHandler.RemoveResidenceUser)
+1
View File
@@ -19,6 +19,7 @@ const (
AuditEventPasswordReset = "auth.password_reset"
AuditEventPasswordChanged = "auth.password_changed"
AuditEventAccountDeleted = "auth.account_deleted"
AuditEventDataExport = "auth.data_export_requested"
)
// AuditService handles audit logging for security-relevant events.
+41
View File
@@ -48,6 +48,33 @@ func (s *AuthService) SetKratosClient(k *kratos.Client) {
s.kratos = k
}
// Register admin-creates a Kratos identity for a new password account with an
// unverified email and no auto-sent verification email (see
// kratos.Client.CreateIdentity for the rationale). The client logs in
// immediately afterward to obtain a session, then drives email verification
// explicitly. No local auth_user row is created here — the KratosAuth
// middleware lazily provisions it on the first authenticated request.
func (s *AuthService) Register(ctx context.Context, req *requests.RegisterRequest) error {
if s.kratos == nil {
return apperrors.Internal(errors.New("identity service unavailable"))
}
_, err := s.kratos.CreateIdentity(ctx, req.Email, req.FirstName, req.LastName, req.Password)
if err == nil {
return nil
}
switch {
case errors.Is(err, kratos.ErrIdentityExists):
return apperrors.Conflict("error.email_already_taken")
default:
var invalid *kratos.ErrInvalidCredentials
if errors.As(err, &invalid) {
return apperrors.BadRequest("error.password_complexity")
}
log.Error().Err(err).Msg("Kratos identity creation failed")
return apperrors.Internal(err)
}
}
// GetCurrentUser returns the current authenticated user with profile.
func (s *AuthService) GetCurrentUser(ctx context.Context, userID uint) (*responses.CurrentUserResponse, error) {
user, err := s.userRepo.WithContext(ctx).FindByIDWithProfile(userID)
@@ -65,6 +92,20 @@ func (s *AuthService) GetCurrentUser(ctx context.Context, userID uint) (*respons
return &response, nil
}
// MarkUserVerified flips the local user_profile.verified and auth_user.verified
// mirrors to true. Called opportunistically from CurrentUser when the live
// Kratos email-verified flag is true but the local mirror is stale (the user
// completed verification after the local row was provisioned). Best-effort:
// the canonical truth lives in Kratos's verifiable_addresses, so a failure
// here only means a brief stale read on background-only queries.
func (s *AuthService) MarkUserVerified(ctx context.Context, userID uint) error {
if err := s.userRepo.WithContext(ctx).MarkVerified(userID); err != nil {
log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to mirror verified flag from Kratos")
return err
}
return nil
}
// UpdateProfile updates a user's profile fields.
func (s *AuthService) UpdateProfile(ctx context.Context, userID uint, req *requests.UpdateProfileRequest) (*responses.CurrentUserResponse, error) {
user, err := s.userRepo.WithContext(ctx).FindByID(userID)
+56 -26
View File
@@ -3,6 +3,7 @@ package services
import (
"context"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"sync"
@@ -12,6 +13,8 @@ import (
"github.com/rs/zerolog/log"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/i18n"
"github.com/treytartt/honeydue-api/internal/prom"
)
// CacheService provides Redis caching functionality
@@ -21,7 +24,7 @@ type CacheService struct {
var (
cacheInstance *CacheService
cacheOnce sync.Once
cacheOnce sync.Once
)
// NewCacheService creates a new cache service (thread-safe via sync.Once)
@@ -92,16 +95,28 @@ func (c *CacheService) Set(ctx context.Context, key string, value interface{}, e
return fmt.Errorf("failed to marshal value: %w", err)
}
return c.client.Set(ctx, key, data, expiration).Err()
err = c.client.Set(ctx, key, data, expiration).Err()
if err != nil {
prom.ObserveCacheOp("set", "error")
} else {
prom.ObserveCacheOp("set", "ok")
}
return err
}
// Get retrieves a value by key
func (c *CacheService) Get(ctx context.Context, key string, dest interface{}) error {
data, err := c.client.Get(ctx, key).Bytes()
if err != nil {
if errors.Is(err, redis.Nil) {
prom.ObserveCacheOp("get", "miss")
} else {
prom.ObserveCacheOp("get", "error")
}
return err
}
prom.ObserveCacheOp("get", "hit")
return json.Unmarshal(data, dest)
}
@@ -133,7 +148,6 @@ func (c *CacheService) Close() error {
return nil
}
// Static data cache helpers
const (
StaticDataKey = "static_data"
@@ -191,9 +205,11 @@ func (c *CacheService) InvalidateAllLookups(ctx context.Context) error {
LookupResidenceTypesKey,
LookupSpecialtiesKey,
LookupTaskTemplatesKey,
StaticDataKey, // Also invalidate the combined static data
SeededDataKey, // Invalidate unified seeded data
SeededDataETagKey, // Invalidate seeded data ETag
StaticDataKey, // Also invalidate the combined static data
}
// Per-locale seeded-data + ETag keys.
for _, lang := range i18n.SupportedLanguages {
keys = append(keys, seededDataKey(lang), seededDataETagKey(lang))
}
return c.Delete(ctx, keys...)
}
@@ -289,50 +305,64 @@ func (c *CacheService) InvalidateTaskTemplates(ctx context.Context) error {
return c.Delete(ctx, LookupTaskTemplatesKey, StaticDataKey)
}
// Unified seeded data cache helpers
// Unified seeded data cache helpers.
//
// The seeded-data payload is localized (lookup display_name + home-profile
// option labels), so the cache and ETag are namespaced per locale. Mixing
// locales under one key would let the first request poison every other
// language and make the ETag meaningless across locales.
const (
SeededDataKey = "seeded_data"
SeededDataETagKey = "seeded_data:etag"
SeededDataTTL = 24 * time.Hour
seededDataPrefix = "seeded_data:"
SeededDataTTL = 24 * time.Hour
)
// CacheSeededData caches the unified seeded data and generates an ETag
func (c *CacheService) CacheSeededData(ctx context.Context, data interface{}) (string, error) {
func seededDataKey(locale string) string { return seededDataPrefix + locale }
func seededDataETagKey(locale string) string { return seededDataPrefix + locale + ":etag" }
// CacheSeededData caches the unified seeded data for a locale and generates an
// ETag. The locale is folded into the ETag so a client switching languages
// always re-fetches rather than getting a stale 304.
func (c *CacheService) CacheSeededData(ctx context.Context, locale string, data interface{}) (string, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return "", fmt.Errorf("failed to marshal seeded data: %w", err)
}
// Generate FNV-64a ETag from the JSON data (faster than MD5, non-cryptographic)
// FNV-64a ETag over locale + JSON (faster than MD5, non-cryptographic).
h := fnv.New64a()
h.Write([]byte(locale))
h.Write([]byte{0})
h.Write(jsonData)
etag := fmt.Sprintf("\"%x\"", h.Sum64())
// Store both the data and the ETag
if err := c.client.Set(ctx, SeededDataKey, jsonData, SeededDataTTL).Err(); err != nil {
if err := c.client.Set(ctx, seededDataKey(locale), jsonData, SeededDataTTL).Err(); err != nil {
return "", fmt.Errorf("failed to cache seeded data: %w", err)
}
if err := c.client.Set(ctx, SeededDataETagKey, etag, SeededDataTTL).Err(); err != nil {
if err := c.client.Set(ctx, seededDataETagKey(locale), etag, SeededDataTTL).Err(); err != nil {
return "", fmt.Errorf("failed to cache seeded data etag: %w", err)
}
return etag, nil
}
// GetCachedSeededData retrieves cached unified seeded data
func (c *CacheService) GetCachedSeededData(ctx context.Context, dest interface{}) error {
return c.Get(ctx, SeededDataKey, dest)
// GetCachedSeededData retrieves cached unified seeded data for a locale.
func (c *CacheService) GetCachedSeededData(ctx context.Context, locale string, dest interface{}) error {
return c.Get(ctx, seededDataKey(locale), dest)
}
// GetSeededDataETag retrieves the cached ETag for seeded data
func (c *CacheService) GetSeededDataETag(ctx context.Context) (string, error) {
return c.GetString(ctx, SeededDataETagKey)
// GetSeededDataETag retrieves the cached ETag for a locale's seeded data.
func (c *CacheService) GetSeededDataETag(ctx context.Context, locale string) (string, error) {
return c.GetString(ctx, seededDataETagKey(locale))
}
// InvalidateSeededData removes cached seeded data and its ETag
// InvalidateSeededData removes cached seeded data and ETags for every
// supported locale (lookup data is locale-independent at the source, so a
// change must clear all language variants).
func (c *CacheService) InvalidateSeededData(ctx context.Context) error {
return c.Delete(ctx, SeededDataKey, SeededDataETagKey)
keys := make([]string, 0, len(i18n.SupportedLanguages)*2)
for _, lang := range i18n.SupportedLanguages {
keys = append(keys, seededDataKey(lang), seededDataETagKey(lang))
}
return c.Delete(ctx, keys...)
}
// === User → Residence-IDs cache ===
+159
View File
@@ -0,0 +1,159 @@
package services
import (
"strings"
goi18n "github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/i18n"
)
// lookup kinds — the message-key namespace for each localizable lookup type.
const (
lookupKindResidenceType = "residence_type"
lookupKindTaskCategory = "task_category"
lookupKindTaskPriority = "task_priority"
lookupKindTaskFrequency = "task_frequency"
lookupKindSpecialty = "contractor_specialty"
lookupKindHomeProfile = "home_profile"
lookupKindDocumentType = "document_type"
lookupKindDocumentCat = "document_category"
)
// documentTypeValues / documentCategoryValues are the stable client enum codes
// (see iOS DocumentType/DocumentCategory). Display labels are localized at
// request time. Order is presentation order.
var documentTypeValues = []string{
"warranty", "manual", "receipt", "inspection", "permit", "deed", "insurance", "contract", "photo", "other",
}
var documentCategoryValues = []string{
"appliance", "hvac", "plumbing", "electrical", "roofing", "structural", "landscaping", "general", "other",
}
// localizedList maps a list of stable values to {value, localized display}.
func localizedList(localizer *goi18n.Localizer, kind string, values []string) []HomeProfileOption {
out := make([]HomeProfileOption, 0, len(values))
for _, v := range values {
out = append(out, HomeProfileOption{
Value: v,
DisplayName: localizeLookup(localizer, kind, v),
})
}
return out
}
// BuildDocumentTypes returns localized document-type options.
func BuildDocumentTypes(localizer *goi18n.Localizer) []HomeProfileOption {
return localizedList(localizer, lookupKindDocumentType, documentTypeValues)
}
// BuildDocumentCategories returns localized document-category options.
func BuildDocumentCategories(localizer *goi18n.Localizer) []HomeProfileOption {
return localizedList(localizer, lookupKindDocumentCat, documentCategoryValues)
}
// lookupSlug normalizes a stable English lookup name (or option value) into a
// message-key slug: lowercased, non-alphanumeric runs collapsed to "_".
// "Pest Control" -> "pest_control", "Bi-Weekly" -> "bi_weekly", "tank_gas" ->
// "tank_gas".
func lookupSlug(name string) string {
var b strings.Builder
prevUnderscore := false
for _, r := range strings.ToLower(strings.TrimSpace(name)) {
switch {
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
b.WriteRune(r)
prevUnderscore = false
default:
if !prevUnderscore && b.Len() > 0 {
b.WriteByte('_')
prevUnderscore = true
}
}
}
return strings.Trim(b.String(), "_")
}
// localizeLookup returns the localized display label for a lookup value.
// Keys follow "lookup.<kind>.<slug>". If the locale lacks the key it falls back
// to English, and ultimately to the original name so a raw key never surfaces.
func localizeLookup(localizer *goi18n.Localizer, kind, name string) string {
key := "lookup." + kind + "." + lookupSlug(name)
msg := i18n.T(localizer, key, nil)
if msg == key {
msg = i18n.T(i18n.NewLocalizer(i18n.DefaultLanguage), key, nil)
}
if msg == key {
// No translation anywhere — fall back to the stable English name.
return name
}
return msg
}
// HomeProfileOption is a single selectable value for a home-profile field.
type HomeProfileOption struct {
Value string `json:"value"`
DisplayName string `json:"display_name"`
}
// homeProfileCatalog is the canonical set of home-profile field options,
// mirroring the (previously hardcoded) iOS dropdowns. Order is presentation
// order. Display labels are localized at request time via localizeLookup.
var homeProfileCatalog = []struct {
Field string
Values []string
}{
{"heating_type", []string{"gas_furnace", "electric_furnace", "heat_pump", "boiler", "radiant", "other"}},
{"cooling_type", []string{"central_ac", "window_ac", "heat_pump", "evaporative", "none", "other"}},
{"water_heater_type", []string{"tank_gas", "tank_electric", "tankless_gas", "tankless_electric", "heat_pump", "solar", "other"}},
{"roof_type", []string{"asphalt_shingle", "metal", "tile", "slate", "wood_shake", "flat", "other"}},
{"exterior_type", []string{"brick", "vinyl_siding", "wood_siding", "stucco", "stone", "fiber_cement", "other"}},
{"flooring_primary", []string{"hardwood", "laminate", "tile", "carpet", "vinyl", "concrete", "other"}},
{"landscaping_type", []string{"lawn", "desert", "xeriscape", "garden", "mixed", "none", "other"}},
}
// BuildHomeProfileOptions returns the home-profile field options with display
// labels localized for the supplied localizer (nil falls back to English).
func BuildHomeProfileOptions(localizer *goi18n.Localizer) map[string][]HomeProfileOption {
out := make(map[string][]HomeProfileOption, len(homeProfileCatalog))
for _, f := range homeProfileCatalog {
opts := make([]HomeProfileOption, 0, len(f.Values))
for _, v := range f.Values {
opts = append(opts, HomeProfileOption{
Value: v,
DisplayName: localizeLookup(localizer, lookupKindHomeProfile, v),
})
}
out[f.Field] = opts
}
return out
}
// LocalizeLookups fills the DisplayName of each lookup slice in place, using the
// supplied localizer. Mutates the passed slices.
func LocalizeLookups(
localizer *goi18n.Localizer,
residenceTypes []responses.ResidenceTypeResponse,
categories []responses.TaskCategoryResponse,
priorities []responses.TaskPriorityResponse,
frequencies []responses.TaskFrequencyResponse,
specialties []responses.ContractorSpecialtyResponse,
) {
for i := range residenceTypes {
residenceTypes[i].DisplayName = localizeLookup(localizer, lookupKindResidenceType, residenceTypes[i].Name)
}
for i := range categories {
categories[i].DisplayName = localizeLookup(localizer, lookupKindTaskCategory, categories[i].Name)
}
for i := range priorities {
priorities[i].DisplayName = localizeLookup(localizer, lookupKindTaskPriority, priorities[i].Name)
}
for i := range frequencies {
frequencies[i].DisplayName = localizeLookup(localizer, lookupKindTaskFrequency, frequencies[i].Name)
}
for i := range specialties {
specialties[i].DisplayName = localizeLookup(localizer, lookupKindSpecialty, specialties[i].Name)
}
}
+88
View File
@@ -0,0 +1,88 @@
package services
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/i18n"
)
func init() {
// Load the embedded translation bundle for the localization tests.
_ = i18n.Init()
}
func TestLookupSlug(t *testing.T) {
cases := map[string]string{
"Pest Control": "pest_control",
"Bi-Weekly": "bi_weekly",
"Semi-Annually": "semi_annually",
"Mobile Home": "mobile_home",
"HVAC": "hvac",
"tank_gas": "tank_gas",
"Tankless (Gas)": "tankless_gas",
" Trailing/Punct ": "trailing_punct",
}
for in, want := range cases {
assert.Equalf(t, want, lookupSlug(in), "slug(%q)", in)
}
}
func TestLocalizeLookup_EnglishAndSpanish(t *testing.T) {
en := i18n.NewLocalizer("en")
es := i18n.NewLocalizer("es")
// Known value: localizes per locale.
assert.Equal(t, "Plumbing", localizeLookup(en, lookupKindTaskCategory, "Plumbing"))
assert.Equal(t, "Fontanería", localizeLookup(es, lookupKindTaskCategory, "Plumbing"))
// Frequency with separators (regression on the bi_weekly/semi_annually slug).
assert.Equal(t, "Bi-Weekly", localizeLookup(en, lookupKindTaskFrequency, "Bi-Weekly"))
assert.Equal(t, "Quincenal", localizeLookup(es, lookupKindTaskFrequency, "Bi-Weekly"))
}
func TestLocalizeLookup_NilLocalizerFallsBackToEnglish(t *testing.T) {
assert.Equal(t, "House", localizeLookup(nil, lookupKindResidenceType, "House"))
}
func TestLocalizeLookup_UnknownValueFallsBackToName(t *testing.T) {
// No translation key exists -> return the stable English name, never a key.
got := localizeLookup(i18n.NewLocalizer("es"), lookupKindTaskCategory, "Totally Unknown Value")
assert.Equal(t, "Totally Unknown Value", got)
}
func TestBuildHomeProfileOptions(t *testing.T) {
opts := BuildHomeProfileOptions(i18n.NewLocalizer("es"))
// All 7 fields present.
for _, field := range []string{"heating_type", "cooling_type", "water_heater_type", "roof_type", "exterior_type", "flooring_primary", "landscaping_type"} {
require.Containsf(t, opts, field, "missing field %s", field)
require.NotEmpty(t, opts[field])
}
// Values are stable; display_name is localized.
heating := opts["heating_type"]
assert.Equal(t, "gas_furnace", heating[0].Value)
assert.Equal(t, "Calefactor de gas", heating[0].DisplayName)
}
func TestLocalizeLookups_FillsDisplayName(t *testing.T) {
cats := []responses.TaskCategoryResponse{{Name: "Plumbing"}, {Name: "HVAC"}}
prios := []responses.TaskPriorityResponse{{Name: "High"}}
freqs := []responses.TaskFrequencyResponse{{Name: "Monthly"}}
specs := []responses.ContractorSpecialtyResponse{{Name: "Plumber"}}
types := []responses.ResidenceTypeResponse{{Name: "House"}}
LocalizeLookups(i18n.NewLocalizer("es"), types, cats, prios, freqs, specs)
assert.Equal(t, "Fontanería", cats[0].DisplayName)
assert.Equal(t, "Plumbing", cats[0].Name) // name stays stable
assert.Equal(t, "Climatización", cats[1].DisplayName)
assert.Equal(t, "Alta", prios[0].DisplayName)
assert.Equal(t, "Mensual", freqs[0].DisplayName)
assert.Equal(t, "Fontanero", specs[0].DisplayName)
assert.Equal(t, "Casa", types[0].DisplayName)
}
+45 -1
View File
@@ -121,7 +121,51 @@ func (s *SubscriptionService) GetSubscription(ctx context.Context, userID uint)
// 5-minute TTL; mutation paths (residence/task/contractor/document/sub CRUD)
// invalidate via cache.InvalidateSubscriptionStatusForUsers, fanning out to
// every member of a shared residence.
func (s *SubscriptionService) GetSubscriptionStatus(ctx context.Context, userID uint) (*SubscriptionStatusResponse, error) {
//
// TEMPORARILY DISABLED — Subscriptions feature off.
//
// Returns a "limitations disabled" / pro-tier stub without any DB or Redis
// work. The KMM client (SubscriptionHelper.kt) treats limitations_enabled=false
// as a master kill switch — every canCreate* check short-circuits to true
// without ever consulting tier or limits. tier="pro" is belt-and-suspenders in
// case a future client path inspects tier directly.
//
// To re-enable: change this body to call s.getSubscriptionStatusFromDB(ctx, userID).
// The original implementation is preserved verbatim below as an unexported
// method so this is a one-line revert.
//
// Findable by searching: "TEMPORARILY DISABLED — Subscriptions"
func (s *SubscriptionService) GetSubscriptionStatus(ctx context.Context, _ uint) (*SubscriptionStatusResponse, error) {
_ = ctx
return &SubscriptionStatusResponse{
Tier: "pro",
IsActive: true,
AutoRenew: false,
LimitationsEnabled: false,
Usage: &UsageResponse{
PropertiesCount: 0,
TasksCount: 0,
ContractorsCount: 0,
DocumentsCount: 0,
},
Limits: map[string]*TierLimitsClientResponse{
// Empty TierLimitsClientResponse → all fields nil → "unlimited"
// per the KMM model's `null = unlimited` convention. Keys present
// so client Map[tier] lookups don't NPE if they ever run.
"free": {},
"pro": {},
},
SubscriptionSource: "none",
TrialActive: false,
}, nil
}
// getSubscriptionStatusFromDB is the original implementation, preserved verbatim
// while the subscription feature is disabled. See the TEMPORARILY DISABLED note
// on GetSubscriptionStatus above for the re-enable procedure.
//
//nolint:unused // intentional — re-enabled by switching GetSubscriptionStatus to call this.
func (s *SubscriptionService) getSubscriptionStatusFromDB(ctx context.Context, userID uint) (*SubscriptionStatusResponse, error) {
// Cache fast path — only used on warm reads. Cold reads, trial-start
// branch, and the actual mutation paths below all populate fresh.
if s.cache != nil {
+132 -70
View File
@@ -3,11 +3,14 @@ package services
import (
"encoding/json"
"sort"
"strings"
goi18n "github.com/nicksnyder/go-i18n/v2/i18n"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/i18n"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
)
@@ -26,25 +29,59 @@ func NewSuggestionService(db *gorm.DB, residenceRepo *repositories.ResidenceRepo
}
}
// stringList is a condition value that may be encoded as either a single JSON
// string ("gas_furnace") or an array of allowed strings (["gas_furnace",
// "boiler"]). The seeded template catalog uses arrays of allowed values; older
// hand-written conditions used a scalar. Accepting both keeps every existing
// condition working — and a scalar `*string` (the previous type) silently
// failed to unmarshal the array form, collapsing every conditioned template to
// "universal".
type stringList []string
// UnmarshalJSON accepts a string or an array of strings.
func (s *stringList) UnmarshalJSON(data []byte) error {
var arr []string
if err := json.Unmarshal(data, &arr); err == nil {
*s = arr
return nil
}
var one string
if err := json.Unmarshal(data, &one); err != nil {
return err
}
*s = stringList{one}
return nil
}
// contains reports whether v is one of the allowed values.
func (s stringList) contains(v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// templateConditions represents the parsed conditions JSON from a task template.
// Every field is optional; a template with no conditions is "universal" and
// receives a small base score. See scoreTemplate for how each field is used.
type templateConditions struct {
HeatingType *string `json:"heating_type,omitempty"`
CoolingType *string `json:"cooling_type,omitempty"`
WaterHeaterType *string `json:"water_heater_type,omitempty"`
RoofType *string `json:"roof_type,omitempty"`
ExteriorType *string `json:"exterior_type,omitempty"`
FlooringPrimary *string `json:"flooring_primary,omitempty"`
LandscapingType *string `json:"landscaping_type,omitempty"`
HasPool *bool `json:"has_pool,omitempty"`
HasSprinkler *bool `json:"has_sprinkler_system,omitempty"`
HasSeptic *bool `json:"has_septic,omitempty"`
HasFireplace *bool `json:"has_fireplace,omitempty"`
HasGarage *bool `json:"has_garage,omitempty"`
HasBasement *bool `json:"has_basement,omitempty"`
HasAttic *bool `json:"has_attic,omitempty"`
PropertyType *string `json:"property_type,omitempty"`
HeatingType stringList `json:"heating_type,omitempty"`
CoolingType stringList `json:"cooling_type,omitempty"`
WaterHeaterType stringList `json:"water_heater_type,omitempty"`
RoofType stringList `json:"roof_type,omitempty"`
ExteriorType stringList `json:"exterior_type,omitempty"`
FlooringPrimary stringList `json:"flooring_primary,omitempty"`
LandscapingType stringList `json:"landscaping_type,omitempty"`
HasPool *bool `json:"has_pool,omitempty"`
HasSprinkler *bool `json:"has_sprinkler_system,omitempty"`
HasSeptic *bool `json:"has_septic,omitempty"`
HasFireplace *bool `json:"has_fireplace,omitempty"`
HasGarage *bool `json:"has_garage,omitempty"`
HasBasement *bool `json:"has_basement,omitempty"`
HasAttic *bool `json:"has_attic,omitempty"`
PropertyType stringList `json:"property_type,omitempty"`
// ClimateRegionID replaces the old task_tasktemplate_regions join table.
// Tag a template with the IECC zone ID it's relevant to (e.g. "Winterize
// Sprinkler" → zone 5/6). Residence.PostalCode is mapped to a region at
@@ -54,12 +91,12 @@ type templateConditions struct {
// isEmpty returns true if no conditions are set
func (c *templateConditions) isEmpty() bool {
return c.HeatingType == nil && c.CoolingType == nil && c.WaterHeaterType == nil &&
c.RoofType == nil && c.ExteriorType == nil && c.FlooringPrimary == nil &&
c.LandscapingType == nil && c.HasPool == nil && c.HasSprinkler == nil &&
return len(c.HeatingType) == 0 && len(c.CoolingType) == 0 && len(c.WaterHeaterType) == 0 &&
len(c.RoofType) == 0 && len(c.ExteriorType) == 0 && len(c.FlooringPrimary) == 0 &&
len(c.LandscapingType) == 0 && c.HasPool == nil && c.HasSprinkler == nil &&
c.HasSeptic == nil && c.HasFireplace == nil && c.HasGarage == nil &&
c.HasBasement == nil && c.HasAttic == nil &&
c.PropertyType == nil && c.ClimateRegionID == nil
len(c.PropertyType) == 0 && c.ClimateRegionID == nil
}
const (
@@ -75,8 +112,10 @@ const (
totalProfileFields = 15 // 14 home-profile fields + ZIP/region
)
// GetSuggestions returns task template suggestions scored against a residence's profile
func (s *SuggestionService) GetSuggestions(residenceID uint, userID uint) (*responses.TaskSuggestionsResponse, error) {
// GetSuggestions returns task template suggestions scored against a residence's
// profile. Match reasons are localized for display via the supplied localizer
// (nil falls back to English).
func (s *SuggestionService) GetSuggestions(residenceID uint, userID uint, localizer *goi18n.Localizer) (*responses.TaskSuggestionsResponse, error) {
// Check access
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
if err != nil {
@@ -112,7 +151,7 @@ func (s *SuggestionService) GetSuggestions(residenceID uint, userID uint) (*resp
suggestions = append(suggestions, responses.TaskSuggestionResponse{
Template: responses.NewTaskTemplateResponse(&templates[i]),
RelevanceScore: score,
MatchReasons: reasons,
MatchReasons: localizeReasons(localizer, reasons),
})
}
@@ -135,6 +174,38 @@ func (s *SuggestionService) GetSuggestions(residenceID uint, userID uint) (*resp
}, nil
}
// localizeReasons converts the internal reason codes emitted by scoreTemplate
// into human-readable, localized strings for the API response.
//
// Codes come in two shapes: a bare feature code ("has_fireplace") and a
// "field:value" pair ("heating_type:gas_furnace"); for the latter we key off
// the field only (the percentage already conveys strength, and a per-enum-value
// catalog would be a much larger surface). The "universal" and "partial_profile"
// signals are internal scoring artifacts, not user-facing reasons, so they're
// dropped. Any code without a translation falls back to English so a raw key
// can never leak to the UI.
func localizeReasons(localizer *goi18n.Localizer, codes []string) []string {
out := make([]string, 0, len(codes))
for _, code := range codes {
if code == "universal" || code == "partial_profile" {
continue
}
field := code
if i := strings.IndexByte(code, ':'); i >= 0 {
field = code[:i]
}
key := "suggestion.reason." + field
msg := i18n.T(localizer, key, nil)
if msg == key {
// Locale lacked the key — fall back to English so the user never
// sees a raw message id.
msg = i18n.T(i18n.NewLocalizer(i18n.DefaultLanguage), key, nil)
}
out = append(out, msg)
}
return out
}
// scoreTemplate scores a template against a residence profile.
// Returns (score, matchReasons, shouldInclude).
func (s *SuggestionService) scoreTemplate(tmpl *models.TaskTemplate, residence *models.Residence) (float64, []string, bool) {
@@ -157,76 +228,62 @@ func (s *SuggestionService) scoreTemplate(tmpl *models.TaskTemplate, residence *
reasons := make([]string, 0)
conditionCount := 0
// String field matches
if cond.HeatingType != nil {
// String field matches. Each condition is a set of allowed values; the
// residence matches when its value is one of them. A nil residence field is
// ignored (no penalty, no exclusion); a mismatch simply earns no bonus.
if len(cond.HeatingType) > 0 {
conditionCount++
if residence.HeatingType == nil {
// Field not set - ignore
} else if *residence.HeatingType == *cond.HeatingType {
if residence.HeatingType != nil && cond.HeatingType.contains(*residence.HeatingType) {
score += stringMatchBonus
reasons = append(reasons, "heating_type:"+*cond.HeatingType)
} else {
// Mismatch - don't exclude, just don't reward
reasons = append(reasons, "heating_type:"+*residence.HeatingType)
}
}
if cond.CoolingType != nil {
if len(cond.CoolingType) > 0 {
conditionCount++
if residence.CoolingType == nil {
// ignore
} else if *residence.CoolingType == *cond.CoolingType {
if residence.CoolingType != nil && cond.CoolingType.contains(*residence.CoolingType) {
score += stringMatchBonus
reasons = append(reasons, "cooling_type:"+*cond.CoolingType)
reasons = append(reasons, "cooling_type:"+*residence.CoolingType)
}
}
if cond.WaterHeaterType != nil {
if len(cond.WaterHeaterType) > 0 {
conditionCount++
if residence.WaterHeaterType == nil {
// ignore
} else if *residence.WaterHeaterType == *cond.WaterHeaterType {
if residence.WaterHeaterType != nil && cond.WaterHeaterType.contains(*residence.WaterHeaterType) {
score += stringMatchBonus
reasons = append(reasons, "water_heater_type:"+*cond.WaterHeaterType)
reasons = append(reasons, "water_heater_type:"+*residence.WaterHeaterType)
}
}
if cond.RoofType != nil {
if len(cond.RoofType) > 0 {
conditionCount++
if residence.RoofType == nil {
// ignore
} else if *residence.RoofType == *cond.RoofType {
if residence.RoofType != nil && cond.RoofType.contains(*residence.RoofType) {
score += stringMatchBonus
reasons = append(reasons, "roof_type:"+*cond.RoofType)
reasons = append(reasons, "roof_type:"+*residence.RoofType)
}
}
if cond.ExteriorType != nil {
if len(cond.ExteriorType) > 0 {
conditionCount++
if residence.ExteriorType == nil {
// ignore
} else if *residence.ExteriorType == *cond.ExteriorType {
if residence.ExteriorType != nil && cond.ExteriorType.contains(*residence.ExteriorType) {
score += stringMatchBonus
reasons = append(reasons, "exterior_type:"+*cond.ExteriorType)
reasons = append(reasons, "exterior_type:"+*residence.ExteriorType)
}
}
if cond.FlooringPrimary != nil {
if len(cond.FlooringPrimary) > 0 {
conditionCount++
if residence.FlooringPrimary == nil {
// ignore
} else if *residence.FlooringPrimary == *cond.FlooringPrimary {
if residence.FlooringPrimary != nil && cond.FlooringPrimary.contains(*residence.FlooringPrimary) {
score += stringMatchBonus
reasons = append(reasons, "flooring_primary:"+*cond.FlooringPrimary)
reasons = append(reasons, "flooring_primary:"+*residence.FlooringPrimary)
}
}
if cond.LandscapingType != nil {
if len(cond.LandscapingType) > 0 {
conditionCount++
if residence.LandscapingType == nil {
// ignore
} else if *residence.LandscapingType == *cond.LandscapingType {
if residence.LandscapingType != nil && cond.LandscapingType.contains(*residence.LandscapingType) {
score += stringMatchBonus
reasons = append(reasons, "landscaping_type:"+*cond.LandscapingType)
reasons = append(reasons, "landscaping_type:"+*residence.LandscapingType)
}
}
@@ -309,11 +366,11 @@ func (s *SuggestionService) scoreTemplate(tmpl *models.TaskTemplate, residence *
}
// Property type match
if cond.PropertyType != nil {
if len(cond.PropertyType) > 0 {
conditionCount++
if residence.PropertyType != nil && residence.PropertyType.Name == *cond.PropertyType {
if residence.PropertyType != nil && cond.PropertyType.contains(residence.PropertyType.Name) {
score += propertyTypeBonus
reasons = append(reasons, "property_type:"+*cond.PropertyType)
reasons = append(reasons, "property_type:"+residence.PropertyType.Name)
}
}
@@ -328,17 +385,22 @@ func (s *SuggestionService) scoreTemplate(tmpl *models.TaskTemplate, residence *
}
}
// Cap at 1.0
if score > 1.0 {
score = 1.0
}
// If template has conditions but no matches and no reasons, still include with low score
// If template has conditions but none matched (residence fields unset or
// different), rank it just below universal — it's a conditioned template we
// couldn't confirm applies.
if conditionCount > 0 && len(reasons) == 0 {
return baseUniversalScore * 0.5, []string{"partial_profile"}, true
}
return score, reasons, true
// A matched conditioned template must rank ABOVE a universal one. Anchor it
// at the universal baseline and add the accumulated match bonuses on top,
// so e.g. a single heating match (0.3 + 0.25) clearly beats universal (0.3).
final := baseUniversalScore + score
if final > 1.0 {
final = 1.0
}
return final, reasons, true
}
// CalculateProfileCompleteness returns how many of the 14 home profile fields are filled
+91 -56
View File
@@ -47,12 +47,12 @@ func TestSuggestionService_UniversalTemplate(t *testing.T) {
// Create universal template (empty conditions)
createTemplateWithConditions(t, service, "Change Air Filters", nil)
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Equal(t, "Change Air Filters", resp.Suggestions[0].Template.Title)
assert.Equal(t, baseUniversalScore, resp.Suggestions[0].RelevanceScore)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "universal")
assert.Empty(t, resp.Suggestions[0].MatchReasons) // universal templates carry no display reason
}
func TestSuggestionService_HeatingTypeMatch(t *testing.T) {
@@ -75,11 +75,47 @@ func TestSuggestionService_HeatingTypeMatch(t *testing.T) {
"heating_type": "gas_furnace",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Equal(t, stringMatchBonus, resp.Suggestions[0].RelevanceScore)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "heating_type:gas_furnace")
// Matched conditioned templates are anchored at the universal baseline and
// earn bonuses on top, so a match always ranks above a universal template.
assert.Equal(t, baseUniversalScore+stringMatchBonus, resp.Suggestions[0].RelevanceScore)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your heating system")
}
// TestSuggestionService_StringConditionArrayForm is the regression test for the
// scorer bug where conditions encoded as an array of allowed values
// (`{"heating_type":["gas_furnace","boiler"]}`, the format used by the seeded
// template catalog) failed to unmarshal into a scalar *string field and the
// template silently collapsed to "universal". The residence's value must match
// when it is any member of the array.
func TestSuggestionService_StringConditionArrayForm(t *testing.T) {
service := setupSuggestionService(t)
user := testutil.CreateTestUser(t, service.db, "owner", "owner@test.com", "password")
heatingType := "boiler" // second value in the allowed list
residence := &models.Residence{
OwnerID: user.ID,
Name: "Boiler House",
IsActive: true,
IsPrimary: true,
HeatingType: &heatingType,
}
require.NoError(t, service.db.Create(residence).Error)
// Array-of-allowed-values form, exactly as the seed catalog stores it.
createTemplateWithConditions(t, service, "Test Gas Shutoffs", map[string]interface{}{
"heating_type": []string{"gas_furnace", "boiler"},
})
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
// Must MATCH (not fall back to universal) and rank above the universal baseline.
assert.Equal(t, baseUniversalScore+stringMatchBonus, resp.Suggestions[0].RelevanceScore)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your heating system")
assert.Len(t, resp.Suggestions[0].MatchReasons, 1)
}
func TestSuggestionService_ExcludedWhenPoolRequiredButFalse(t *testing.T) {
@@ -94,7 +130,7 @@ func TestSuggestionService_ExcludedWhenPoolRequiredButFalse(t *testing.T) {
"has_pool": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
assert.Len(t, resp.Suggestions, 0) // Should be excluded
}
@@ -111,11 +147,11 @@ func TestSuggestionService_NilFieldIgnored(t *testing.T) {
"heating_type": "gas_furnace",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
// Should be included (not excluded) but with low partial score
assert.Contains(t, resp.Suggestions[0].MatchReasons, "partial_profile")
assert.Empty(t, resp.Suggestions[0].MatchReasons) // conditioned-but-unmatched carries no display reason
}
func TestSuggestionService_ProfileCompleteness(t *testing.T) {
@@ -140,7 +176,7 @@ func TestSuggestionService_ProfileCompleteness(t *testing.T) {
// Create at least one template so we get a response
createTemplateWithConditions(t, service, "Universal Task", nil)
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
// 4 fields filled out of 15 (home-profile fields + ZIP/region)
expectedCompleteness := 4.0 / float64(totalProfileFields)
@@ -176,7 +212,7 @@ func TestSuggestionService_SortedByScoreDescending(t *testing.T) {
"has_pool": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 3)
@@ -193,7 +229,7 @@ func TestSuggestionService_AccessDenied(t *testing.T) {
residence := testutil.CreateTestResidence(t, service.db, owner.ID, "Private House")
_, err := service.GetSuggestions(residence.ID, stranger.ID)
_, err := service.GetSuggestions(residence.ID, stranger.ID, nil)
require.Error(t, err)
testutil.AssertAppErrorCode(t, err, 403)
}
@@ -222,12 +258,13 @@ func TestSuggestionService_MultipleConditionsAllMustMatch(t *testing.T) {
"heating_type": "gas_furnace",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
// All three conditions matched
expectedScore := boolMatchBonus + boolMatchBonus + stringMatchBonus // 0.3 + 0.3 + 0.25 = 0.85
// All three conditions matched. Anchored baseline (0.3) + 0.3 + 0.3 + 0.25
// = 1.15, capped at 1.0.
expectedScore := 1.0
assert.InDelta(t, expectedScore, resp.Suggestions[0].RelevanceScore, 0.01)
assert.Len(t, resp.Suggestions[0].MatchReasons, 3)
}
@@ -248,7 +285,7 @@ func TestSuggestionService_MalformedConditions(t *testing.T) {
err := service.db.Create(tmpl).Error
require.NoError(t, err)
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
// Should be treated as universal
@@ -270,7 +307,7 @@ func TestSuggestionService_NullConditions(t *testing.T) {
err := service.db.Create(tmpl).Error
require.NoError(t, err)
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Equal(t, baseUniversalScore, resp.Suggestions[0].RelevanceScore)
@@ -304,10 +341,10 @@ func TestSuggestionService_PropertyTypeMatch(t *testing.T) {
"property_type": "House",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "property_type:House")
assert.Contains(t, resp.Suggestions[0].MatchReasons, "Recommended for your property type")
}
// === CalculateProfileCompleteness with fully filled profile ===
@@ -392,7 +429,7 @@ func TestSuggestionService_ScoreCappedAtOne(t *testing.T) {
"has_garage": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.LessOrEqual(t, resp.Suggestions[0].RelevanceScore, 1.0)
@@ -409,7 +446,7 @@ func TestSuggestionService_InactiveTemplateExcluded(t *testing.T) {
err := service.db.Exec("INSERT INTO task_tasktemplate (title, is_active, conditions, created_at, updated_at) VALUES ('Inactive Task', false, '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)").Error
require.NoError(t, err)
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
assert.Len(t, resp.Suggestions, 0)
}
@@ -425,7 +462,7 @@ func TestSuggestionService_ExcludedWhenSprinklerRequired(t *testing.T) {
"has_sprinkler_system": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
assert.Len(t, resp.Suggestions, 0)
}
@@ -441,7 +478,7 @@ func TestSuggestionService_ExcludedWhenSepticRequired(t *testing.T) {
"has_septic": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
assert.Len(t, resp.Suggestions, 0)
}
@@ -455,7 +492,7 @@ func TestSuggestionService_ExcludedWhenFireplaceRequired(t *testing.T) {
"has_fireplace": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
assert.Len(t, resp.Suggestions, 0)
}
@@ -469,7 +506,7 @@ func TestSuggestionService_ExcludedWhenGarageRequired(t *testing.T) {
"has_garage": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
assert.Len(t, resp.Suggestions, 0)
}
@@ -483,7 +520,7 @@ func TestSuggestionService_ExcludedWhenBasementRequired(t *testing.T) {
"has_basement": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
assert.Len(t, resp.Suggestions, 0)
}
@@ -497,7 +534,7 @@ func TestSuggestionService_ExcludedWhenAtticRequired(t *testing.T) {
"has_attic": true,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
assert.Len(t, resp.Suggestions, 0)
}
@@ -523,10 +560,10 @@ func TestSuggestionService_CoolingTypeMatch(t *testing.T) {
"cooling_type": "central_ac",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "cooling_type:central_ac")
assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your cooling system")
}
func TestSuggestionService_WaterHeaterTypeMatch(t *testing.T) {
@@ -548,10 +585,10 @@ func TestSuggestionService_WaterHeaterTypeMatch(t *testing.T) {
"water_heater_type": "tank_gas",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "water_heater_type:tank_gas")
assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your water heater")
}
func TestSuggestionService_ExteriorTypeMatch(t *testing.T) {
@@ -573,10 +610,10 @@ func TestSuggestionService_ExteriorTypeMatch(t *testing.T) {
"exterior_type": "brick",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "exterior_type:brick")
assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your exterior")
}
func TestSuggestionService_FlooringPrimaryMatch(t *testing.T) {
@@ -598,10 +635,10 @@ func TestSuggestionService_FlooringPrimaryMatch(t *testing.T) {
"flooring_primary": "hardwood",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "flooring_primary:hardwood")
assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your flooring")
}
func TestSuggestionService_LandscapingTypeMatch(t *testing.T) {
@@ -623,10 +660,10 @@ func TestSuggestionService_LandscapingTypeMatch(t *testing.T) {
"landscaping_type": "lawn",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "landscaping_type:lawn")
assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your landscaping")
}
func TestSuggestionService_RoofTypeMatch(t *testing.T) {
@@ -648,10 +685,10 @@ func TestSuggestionService_RoofTypeMatch(t *testing.T) {
"roof_type": "asphalt_shingle",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "roof_type:asphalt_shingle")
assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your roof")
}
// === Mismatch on string field — no score for that field ===
@@ -676,11 +713,11 @@ func TestSuggestionService_HeatingTypeMismatch(t *testing.T) {
"heating_type": "gas_furnace",
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
// Should still be included but with partial_profile (no match, no exclude)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "partial_profile")
assert.Empty(t, resp.Suggestions[0].MatchReasons) // conditioned-but-unmatched carries no display reason
}
// === templateConditions.isEmpty ===
@@ -689,16 +726,14 @@ func TestTemplateConditions_IsEmpty(t *testing.T) {
cond := &templateConditions{}
assert.True(t, cond.isEmpty())
ht := "gas"
cond2 := &templateConditions{HeatingType: &ht}
cond2 := &templateConditions{HeatingType: stringList{"gas"}}
assert.False(t, cond2.isEmpty())
pool := true
cond3 := &templateConditions{HasPool: &pool}
assert.False(t, cond3.isEmpty())
pt := "House"
cond4 := &templateConditions{PropertyType: &pt}
cond4 := &templateConditions{PropertyType: stringList{"House"}}
assert.False(t, cond4.isEmpty())
var regionID uint = 5
@@ -727,11 +762,11 @@ func TestSuggestionService_ClimateRegionMatch(t *testing.T) {
"climate_region_id": 5,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
assert.InDelta(t, climateRegionBonus, resp.Suggestions[0].RelevanceScore, 0.001)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "climate_region")
assert.InDelta(t, baseUniversalScore+climateRegionBonus, resp.Suggestions[0].RelevanceScore, 0.001)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "Recommended for your climate")
}
func TestSuggestionService_ClimateRegionMismatch(t *testing.T) {
@@ -753,11 +788,11 @@ func TestSuggestionService_ClimateRegionMismatch(t *testing.T) {
"climate_region_id": 6,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1) // Still included — mismatch doesn't exclude
assert.InDelta(t, baseUniversalScore*0.5, resp.Suggestions[0].RelevanceScore, 0.001)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "partial_profile")
assert.Empty(t, resp.Suggestions[0].MatchReasons) // conditioned-but-unmatched carries no display reason
}
func TestSuggestionService_ClimateRegionIgnoredWhenNoZip(t *testing.T) {
@@ -779,7 +814,7 @@ func TestSuggestionService_ClimateRegionIgnoredWhenNoZip(t *testing.T) {
"climate_region_id": 5,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1) // Still included, just no bonus
assert.InDelta(t, baseUniversalScore*0.5, resp.Suggestions[0].RelevanceScore, 0.001)
@@ -802,11 +837,11 @@ func TestSuggestionService_ClimateRegionUnknownZip(t *testing.T) {
"climate_region_id": 5,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
// Unknown ZIP → 0 region → no match, but no crash
assert.Contains(t, resp.Suggestions[0].MatchReasons, "partial_profile")
assert.Empty(t, resp.Suggestions[0].MatchReasons) // conditioned-but-unmatched carries no display reason
}
func TestSuggestionService_ClimateRegionStacksWithOtherConditions(t *testing.T) {
@@ -829,11 +864,11 @@ func TestSuggestionService_ClimateRegionStacksWithOtherConditions(t *testing.T)
"climate_region_id": 5,
})
resp, err := service.GetSuggestions(residence.ID, user.ID)
resp, err := service.GetSuggestions(residence.ID, user.ID, nil)
require.NoError(t, err)
require.Len(t, resp.Suggestions, 1)
// Both bonuses should apply: stringMatchBonus + climateRegionBonus
assert.InDelta(t, stringMatchBonus+climateRegionBonus, resp.Suggestions[0].RelevanceScore, 0.001)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "heating_type:gas_furnace")
assert.Contains(t, resp.Suggestions[0].MatchReasons, "climate_region")
assert.InDelta(t, baseUniversalScore+stringMatchBonus+climateRegionBonus, resp.Suggestions[0].RelevanceScore, 0.001)
assert.Contains(t, resp.Suggestions[0].MatchReasons, "Matches your heating system")
assert.Contains(t, resp.Suggestions[0].MatchReasons, "Recommended for your climate")
}
+85 -11
View File
@@ -29,6 +29,16 @@ var (
ErrCompletionNotFound = errors.New("task completion not found")
)
// TaskCompletedNotificationEnqueuer is the narrow contract TaskService needs
// from the Asynq client to offload completion-notification fan-out (push +
// email + B2 image fetches) into the background worker. Implemented by
// internal/worker.TaskClient; nil when the api is started without a worker
// queue (tests / local dev), in which case CreateCompletion falls back to
// the inline synchronous path.
type TaskCompletedNotificationEnqueuer interface {
EnqueueTaskCompletedNotification(taskID, completionID uint) error
}
// TaskService handles task business logic
type TaskService struct {
taskRepo *repositories.TaskRepository
@@ -39,6 +49,7 @@ type TaskService struct {
storageService *StorageService
uploadService *UploadService // optional — only set when S3 storage is configured
cache *CacheService
notifEnqueuer TaskCompletedNotificationEnqueuer
}
// SetUploadService wires the presigned-URL upload service so CreateCompletion
@@ -82,6 +93,14 @@ func (s *TaskService) SetStorageService(ss *StorageService) {
s.storageService = ss
}
// SetTaskCompletedNotificationEnqueuer wires the Asynq enqueuer. When set,
// CreateCompletion/QuickComplete schedule the notification fan-out as a
// background job instead of running it inline (saving ~1-1.5s on the
// user-facing response). When nil, the inline path runs.
func (s *TaskService) SetTaskCompletedNotificationEnqueuer(e TaskCompletedNotificationEnqueuer) {
s.notifEnqueuer = e
}
// getSummaryForUser returns an empty summary placeholder.
// DEPRECATED: Summary calculation has been removed from CRUD responses for performance.
// Clients should calculate summary from kanban data instead (which already includes all tasks).
@@ -769,8 +788,11 @@ func (s *TaskService) CreateCompletion(ctx context.Context, req *requests.Create
}, nil
}
// Send notification to residence owner and other users
s.sendTaskCompletedNotification(ctx, task, completion)
// Dispatch notification fan-out to the Asynq worker so the api response
// returns without waiting on APNs + SMTP + B2 image fetches (which cost
// ~1-1.5s end-to-end). Falls back to the inline path when no enqueuer is
// wired (tests / local dev) or when Redis is unreachable.
s.dispatchTaskCompletedNotification(ctx, task, completion)
// Return completion with updated task (includes kanban_column for UI update)
resp := responses.NewTaskCompletionWithTaskResponseWithTime(completion, task, 30, now)
@@ -876,19 +898,71 @@ func (s *TaskService) QuickComplete(ctx context.Context, taskID uint, userID uin
}
log.Info().Uint("task_id", task.ID).Msg("QuickComplete: Task updated successfully")
// Send notification (fire and forget with panic recovery)
go func() {
defer func() {
if r := recover(); r != nil {
log.Error().Interface("panic", r).Uint("task_id", task.ID).Msg("Panic in quick-complete notification goroutine")
}
}()
s.sendTaskCompletedNotification(ctx, task, completion)
}()
// Dispatch notification fan-out to the Asynq worker. Replaces the previous
// fire-and-forget goroutine — which violated the project rule against
// spawning goroutines in request handlers and was unbounded under load.
// Falls back to inline send when no enqueuer is wired.
s.dispatchTaskCompletedNotification(ctx, task, completion)
return nil
}
// dispatchTaskCompletedNotification routes the notification fan-out to either
// the Asynq worker (preferred — keeps the api request path fast) or runs it
// inline as a fallback. The fallback covers:
// - tests / local dev where no enqueuer is wired (notifEnqueuer == nil)
// - Redis outages where Enqueue returns an error
//
// Inline fallback is intentionally synchronous (no goroutines) per the
// project's rule against unbounded goroutine spawning in handlers. The
// caller is expected to be in a request goroutine and accepting the cost.
func (s *TaskService) dispatchTaskCompletedNotification(ctx context.Context, task *models.Task, completion *models.TaskCompletion) {
if s.notifEnqueuer != nil {
if err := s.notifEnqueuer.EnqueueTaskCompletedNotification(task.ID, completion.ID); err == nil {
return
} else {
log.Warn().
Err(err).
Uint("task_id", task.ID).
Uint("completion_id", completion.ID).
Msg("Failed to enqueue completion notification; falling back to inline send")
}
}
s.sendTaskCompletedNotification(ctx, task, completion)
}
// SendTaskCompletedNotificationByID is the public entry point used by the
// Asynq worker. It re-reads the canonical Task + TaskCompletion rows from
// Postgres (cheap with Neon ~10ms away) so the worker reflects any concurrent
// edits between enqueue and dequeue, then delegates to the shared
// sendTaskCompletedNotification implementation.
//
// Returns nil for "row not found" cases (task or completion was deleted before
// the job ran) so Asynq's retry loop doesn't churn on a permanent miss. All
// other errors propagate to Asynq for retry per the queue's MaxRetry setting.
func (s *TaskService) SendTaskCompletedNotificationByID(ctx context.Context, taskID, completionID uint) error {
task, err := s.taskRepo.WithContext(ctx).FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
log.Warn().Uint("task_id", taskID).Msg("task not found for completion notification (likely deleted between enqueue and dequeue)")
return nil
}
return err
}
completion, err := s.taskRepo.WithContext(ctx).FindCompletionByID(completionID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
log.Warn().Uint("completion_id", completionID).Msg("completion not found for notification (likely deleted between enqueue and dequeue)")
return nil
}
return err
}
s.sendTaskCompletedNotification(ctx, task, completion)
return nil
}
// sendTaskCompletedNotification sends notifications when a task is completed
func (s *TaskService) sendTaskCompletedNotification(ctx context.Context, task *models.Task, completion *models.TaskCompletion) {
// Get all users with access to this residence
+20 -1
View File
@@ -2,17 +2,27 @@ package worker
import "encoding/json"
// Enqueuer defines the interface for enqueuing background email tasks.
// Enqueuer defines the interface for enqueuing background email + notification
// tasks from the api request path. Implementations are expected to be cheap to
// call and non-blocking (Asynq's client batches over a persistent Redis
// connection).
type Enqueuer interface {
EnqueueWelcomeEmail(to, firstName, code string) error
EnqueueVerificationEmail(to, firstName, code string) error
EnqueuePasswordResetEmail(to, firstName, code, resetToken string) error
EnqueuePasswordChangedEmail(to, firstName string) error
EnqueueTaskCompletedNotification(taskID, completionID uint) error
EnqueueDataExport(userID uint) error
}
// Verify TaskClient satisfies the interface at compile time.
var _ Enqueuer = (*TaskClient)(nil)
// BuildDataExportPayload marshals a DataExportPayload to JSON bytes.
func BuildDataExportPayload(userID uint) ([]byte, error) {
return json.Marshal(DataExportPayload{UserID: userID})
}
// BuildWelcomeEmailPayload marshals a WelcomeEmailPayload to JSON bytes.
func BuildWelcomeEmailPayload(to, firstName, code string) ([]byte, error) {
return json.Marshal(WelcomeEmailPayload{
@@ -42,3 +52,12 @@ func BuildPasswordResetEmailPayload(to, firstName, code, resetToken string) ([]b
func BuildPasswordChangedEmailPayload(to, firstName string) ([]byte, error) {
return json.Marshal(EmailPayload{To: to, FirstName: firstName})
}
// BuildTaskCompletedNotificationPayload marshals a TaskCompletedNotificationPayload
// to JSON bytes for the Asynq queue.
func BuildTaskCompletedNotificationPayload(taskID, completionID uint) ([]byte, error) {
return json.Marshal(TaskCompletedNotificationPayload{
TaskID: taskID,
CompletionID: completionID,
})
}
+69
View File
@@ -0,0 +1,69 @@
package jobs
import (
"context"
"time"
"github.com/hibiken/asynq"
"github.com/rs/zerolog/log"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
)
// Data-retention cleanup job types. Registered as periodic crons in
// cmd/worker/main.go. These keep transient/log tables from growing unbounded;
// none touch user-facing data that the app reads back.
const (
TypeNotificationCleanup = "maintenance:notification_cleanup"
TypeWebhookLogCleanup = "maintenance:webhook_log_cleanup"
TypeAuditLogCleanup = "maintenance:audit_log_cleanup"
)
// Retention windows (days).
const (
notificationRetentionDays = 90
webhookLogRetentionDays = 180
auditLogRetentionDays = 365 // keep 1 year of security events
)
// HandleNotificationCleanup deletes notification rows older than the retention
// window. Notifications are delivery records (push/digest history); 90 days is
// ample for any in-app history a client might show.
func (h *Handler) HandleNotificationCleanup(ctx context.Context, _ *asynq.Task) error {
cutoff := time.Now().UTC().AddDate(0, 0, -notificationRetentionDays)
res := h.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(&models.Notification{})
if res.Error != nil {
log.Error().Err(res.Error).Msg("notification cleanup failed")
return res.Error
}
log.Info().Int64("deleted", res.RowsAffected).Int("retention_days", notificationRetentionDays).Msg("notification cleanup completed")
return nil
}
// HandleWebhookLogCleanup prunes the webhook dedup log. Rows only matter for the
// window in which a provider (Apple/Google) might redeliver an event; 180 days
// is a generous safety margin past any real redelivery.
func (h *Handler) HandleWebhookLogCleanup(ctx context.Context, _ *asynq.Task) error {
cutoff := time.Now().UTC().AddDate(0, 0, -webhookLogRetentionDays)
res := h.db.WithContext(ctx).Where("processed_at < ?", cutoff).Delete(&repositories.WebhookEvent{})
if res.Error != nil {
log.Error().Err(res.Error).Msg("webhook log cleanup failed")
return res.Error
}
log.Info().Int64("deleted", res.RowsAffected).Int("retention_days", webhookLogRetentionDays).Msg("webhook log cleanup completed")
return nil
}
// HandleAuditLogCleanup prunes audit events older than the retention window.
// One year of security events is retained for compliance/forensics.
func (h *Handler) HandleAuditLogCleanup(ctx context.Context, _ *asynq.Task) error {
cutoff := time.Now().UTC().AddDate(0, 0, -auditLogRetentionDays)
res := h.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(&models.AuditLog{})
if res.Error != nil {
log.Error().Err(res.Error).Msg("audit log cleanup failed")
return res.Error
}
log.Info().Int64("deleted", res.RowsAffected).Int("retention_days", auditLogRetentionDays).Msg("audit log cleanup completed")
return nil
}
+138
View File
@@ -0,0 +1,138 @@
package jobs
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"time"
"github.com/hibiken/asynq"
"github.com/rs/zerolog/log"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/worker"
)
// HandleDataExport gathers all of a user's data (GDPR data portability), zips it
// as one JSON file per category, and emails the archive as an attachment.
// Triggered by POST /api/auth/export/ -> Enqueuer.EnqueueDataExport.
//
// Residence-scoped data (tasks, contractors, documents, share codes) covers only
// residences the user OWNS — shared residences belong to their owner and are
// intentionally excluded. Document/photo *files* are referenced by URL (in
// documents.json); the bytes live in B2 and aren't inlined.
func (h *Handler) HandleDataExport(ctx context.Context, task *asynq.Task) error {
var payload worker.DataExportPayload
if err := json.Unmarshal(task.Payload(), &payload); err != nil {
log.Error().Err(err).Msg("data export: malformed payload")
return asynq.SkipRetry
}
db := h.db.WithContext(ctx)
var user models.User
if err := db.First(&user, payload.UserID).Error; err != nil {
log.Error().Err(err).Uint("user_id", payload.UserID).Msg("data export: user not found")
return err
}
if h.emailService == nil {
log.Warn().Uint("user_id", payload.UserID).Msg("data export: email service unavailable; cannot deliver")
return nil // retrying won't help a structurally-disabled mailer
}
var ownedIDs []uint
db.Model(&models.Residence{}).Where("owner_id = ?", payload.UserID).Pluck("id", &ownedIDs)
var (
profile []models.UserProfile
residences []models.Residence
tasks []models.Task
contractors []models.Contractor
documents []models.Document
shareCodes []models.ResidenceShareCode
notifs []models.Notification
notifPrefs []models.NotificationPreference
apnsDevices []models.APNSDevice
gcmDevices []models.GCMDevice
subscription []models.UserSubscription
auditLog []models.AuditLog
)
db.Where("user_id = ?", payload.UserID).Find(&profile)
db.Where("owner_id = ?", payload.UserID).Find(&residences)
if len(ownedIDs) > 0 {
db.Where("residence_id IN ?", ownedIDs).Find(&tasks)
db.Where("residence_id IN ?", ownedIDs).Find(&contractors)
db.Where("residence_id IN ?", ownedIDs).Find(&documents)
db.Where("residence_id IN ?", ownedIDs).Find(&shareCodes)
}
db.Where("user_id = ?", payload.UserID).Find(&notifs)
db.Where("user_id = ?", payload.UserID).Find(&notifPrefs)
db.Where("user_id = ?", payload.UserID).Find(&apnsDevices)
db.Where("user_id = ?", payload.UserID).Find(&gcmDevices)
db.Where("user_id = ?", payload.UserID).Find(&subscription)
db.Where("user_id = ?", payload.UserID).Find(&auditLog)
sections := []struct {
name string
data interface{}
}{
{"account", user},
{"profile", profile},
{"residences", residences},
{"tasks", tasks},
{"contractors", contractors},
{"documents", documents},
{"share_codes", shareCodes},
{"notifications", notifs},
{"notification_preferences", notifPrefs},
{"push_tokens_ios", apnsDevices},
{"push_tokens_android", gcmDevices},
{"subscription", subscription},
{"audit_log", auditLog},
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
readme := fmt.Sprintf("honeyDue data export\nGenerated: %s UTC\nAccount: %s\n\n"+
"One JSON file per data category. Residence-scoped data covers residences you own.\n"+
"Document and photo files are referenced by URL in documents.json.\n",
time.Now().UTC().Format(time.RFC3339), user.Email)
if w, err := zw.Create("README.txt"); err == nil {
_, _ = w.Write([]byte(readme))
}
for _, s := range sections {
w, err := zw.Create(s.name + ".json")
if err != nil {
_ = zw.Close()
return fmt.Errorf("data export: zip create %s: %w", s.name, err)
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(s.data); err != nil {
_ = zw.Close()
return fmt.Errorf("data export: encode %s: %w", s.name, err)
}
}
if err := zw.Close(); err != nil {
return fmt.Errorf("data export: finalize zip: %w", err)
}
subject := "Your honeyDue data export"
text := "Attached is a copy of your honeyDue data, as a zip of JSON files.\n" +
"If you didn't request this, you can ignore this email.\n"
html := "<p>Attached is a copy of your honeyDue data, as a zip of JSON files.</p>" +
"<p>If you didn't request this, you can ignore this email.</p>"
attach := &services.EmailAttachment{
Filename: "honeydue-data-export.zip",
ContentType: "application/zip",
Data: buf.Bytes(),
}
if err := h.emailService.SendEmailWithAttachment(user.Email, subject, html, text, attach); err != nil {
log.Error().Err(err).Uint("user_id", payload.UserID).Msg("data export: email send failed")
return err
}
log.Info().Uint("user_id", payload.UserID).Int("zip_bytes", buf.Len()).Msg("data export emailed")
return nil
}
+53
View File
@@ -16,6 +16,7 @@ import (
"github.com/treytartt/honeydue-api/internal/push"
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/worker"
)
// Task types
@@ -41,6 +42,7 @@ type Handler struct {
notificationService NotificationSender
onboardingService OnboardingEmailSender
uploadService *services.UploadService
taskService *services.TaskService
config *config.Config
}
@@ -51,6 +53,14 @@ func (h *Handler) SetUploadService(us *services.UploadService) {
h.uploadService = us
}
// SetTaskService wires the api-side TaskService so HandleTaskCompletedNotification
// can re-use the same SendTaskCompletedNotificationByID logic the inline path
// used to call. Required for the task-completed notification job; without it
// the handler logs a warning and no-ops (notifications silently dropped).
func (h *Handler) SetTaskService(ts *services.TaskService) {
h.taskService = ts
}
// NewHandler creates a new job handler
func NewHandler(db *gorm.DB, pushClient *push.Client, emailService *services.EmailService, notificationService *services.NotificationService, cfg *config.Config) *Handler {
h := &Handler{
@@ -677,3 +687,46 @@ func (h *Handler) HandleUploadCleanup(ctx context.Context, task *asynq.Task) err
log.Info().Int("reaped", reaped).Msg("Pending uploads cleanup completed")
return nil
}
// HandleTaskCompletedNotification fans out push + email notifications for a
// completed task. Enqueued by the api request handler (POST
// /api/task-completions/) so the synchronous chain of APNs + SMTP + B2 image
// fetches happens here instead of in the user-facing request path.
//
// The payload only carries IDs; canonical state is re-read from Postgres so
// the worker reflects any concurrent edits to the Task or Completion that
// happened between enqueue and dequeue.
//
// Asynq retries on returned error; we return nil for "row not found" cases
// (task or completion got deleted before the job ran) so retries don't
// loop forever on a permanent miss.
func (h *Handler) HandleTaskCompletedNotification(ctx context.Context, t *asynq.Task) error {
var p worker.TaskCompletedNotificationPayload
if err := json.Unmarshal(t.Payload(), &p); err != nil {
return fmt.Errorf("unmarshal task_completed_notification payload: %w", err)
}
if h.taskService == nil {
log.Warn().
Uint("task_id", p.TaskID).
Uint("completion_id", p.CompletionID).
Msg("task_completed_notification handler invoked without TaskService wired — dropping job")
return nil
}
log.Info().
Uint("task_id", p.TaskID).
Uint("completion_id", p.CompletionID).
Msg("Processing task completion notification")
if err := h.taskService.SendTaskCompletedNotificationByID(ctx, p.TaskID, p.CompletionID); err != nil {
log.Error().
Err(err).
Uint("task_id", p.TaskID).
Uint("completion_id", p.CompletionID).
Msg("Failed to deliver task completion notification")
return err
}
return nil
}
+8
View File
@@ -12,6 +12,7 @@ import (
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/services"
)
// --- Mock implementations ---
@@ -27,6 +28,13 @@ func (m *mockEmailSender) SendEmail(to, subject, htmlBody, textBody string) erro
return nil
}
func (m *mockEmailSender) SendEmailWithAttachment(to, subject, htmlBody, textBody string, _ *services.EmailAttachment) error {
if m.sendFn != nil {
return m.sendFn(to, subject, htmlBody, textBody)
}
return nil
}
type mockPushSender struct {
sendFn func(ctx context.Context, iosTokens, androidTokens []string, title, message string, data map[string]string) error
}
+2
View File
@@ -6,6 +6,7 @@ import (
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/services"
)
// TaskRepo defines task query operations needed by job handlers.
@@ -46,6 +47,7 @@ type PushSender interface {
// EmailSender sends emails.
type EmailSender interface {
SendEmail(to, subject, htmlBody, textBody string) error
SendEmailWithAttachment(to, subject, htmlBody, textBody string, attachment *services.EmailAttachment) error
}
// OnboardingEmailSender sends onboarding campaign emails.
+85 -4
View File
@@ -13,6 +13,34 @@ const (
TypePasswordChangedEmail = "email:password_changed"
)
// Task types for in-app notifications enqueued by the api request path.
// Handlers live in internal/worker/jobs.
const (
// TypeTaskCompletedNotification is emitted after a task completion is
// persisted; the worker fans out push + email to all residence users.
// Moves the ~1-1.5s of synchronous APNs+SMTP+B2-fetch work out of the
// POST /api/task-completions/ request path.
TypeTaskCompletedNotification = "notification:task_completed"
// TypeDataExport is emitted by POST /api/auth/export/. The worker gathers
// all of the user's data into a zip and emails it (GDPR data portability).
TypeDataExport = "user:data_export"
)
// DataExportPayload carries just the user id; the worker re-fetches all rows.
type DataExportPayload struct {
UserID uint `json:"user_id"`
}
// TaskCompletedNotificationPayload carries only the IDs needed for the
// worker to re-fetch the canonical Task + TaskCompletion rows. Keeping the
// payload to IDs (vs. full model graphs) keeps the Redis queue cheap and
// avoids serialising preloaded relations through JSON.
type TaskCompletedNotificationPayload struct {
TaskID uint `json:"task_id"`
CompletionID uint `json:"completion_id"`
}
// EmailPayload is the base payload for email tasks
type EmailPayload struct {
To string `json:"to"`
@@ -43,10 +71,12 @@ type TaskClient struct {
client *asynq.Client
}
// NewTaskClient creates a new task client
func NewTaskClient(redisAddr string) *TaskClient {
client := asynq.NewClient(asynq.RedisClientOpt{Addr: redisAddr})
return &TaskClient{client: client}
// NewTaskClient creates a new task client. Accepts a full RedisClientOpt so
// callers can supply Addr + Password (Redis is requirepass-protected; the
// password lives in a file-mounted secret, not the URL — see audit HIGH-1
// in cmd/worker/main.go).
func NewTaskClient(opt asynq.RedisClientOpt) *TaskClient {
return &TaskClient{client: asynq.NewClient(opt)}
}
// Close closes the task client
@@ -72,6 +102,26 @@ func (c *TaskClient) EnqueueWelcomeEmail(to, firstName, code string) error {
return nil
}
// EnqueueDataExport enqueues a GDPR data-export task for a user. The worker
// gathers the user's data, zips it, and emails it. Low priority — there's no
// rush, and it shouldn't compete with notifications for the critical queue.
func (c *TaskClient) EnqueueDataExport(userID uint) error {
payload, err := BuildDataExportPayload(userID)
if err != nil {
return err
}
task := asynq.NewTask(TypeDataExport, payload)
_, err = c.client.Enqueue(task, asynq.Queue("low"), asynq.MaxRetry(3))
if err != nil {
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to enqueue data export")
return err
}
log.Info().Uint("user_id", userID).Msg("Data export task enqueued")
return nil
}
// EnqueueVerificationEmail enqueues a verification email task
func (c *TaskClient) EnqueueVerificationEmail(to, firstName, code string) error {
payload, err := BuildVerificationEmailPayload(to, firstName, code)
@@ -125,3 +175,34 @@ func (c *TaskClient) EnqueuePasswordChangedEmail(to, firstName string) error {
log.Debug().Str("to", to).Msg("Password changed email task enqueued")
return nil
}
// EnqueueTaskCompletedNotification queues fan-out push + email delivery for a
// completed task. The api request handler calls this so the response can
// return ~immediately instead of waiting on APNs + SMTP + B2 image fetches.
//
// Queue: "default". MaxRetry: 3 (Asynq retries on handler error; notifications
// are idempotent enough at the user-perception level that a few duplicate
// pushes are preferable to silent drops).
func (c *TaskClient) EnqueueTaskCompletedNotification(taskID, completionID uint) error {
payload, err := BuildTaskCompletedNotificationPayload(taskID, completionID)
if err != nil {
return err
}
task := asynq.NewTask(TypeTaskCompletedNotification, payload)
_, err = c.client.Enqueue(task, asynq.Queue("default"), asynq.MaxRetry(3))
if err != nil {
log.Error().
Err(err).
Uint("task_id", taskID).
Uint("completion_id", completionID).
Msg("Failed to enqueue task completion notification")
return err
}
log.Debug().
Uint("task_id", taskID).
Uint("completion_id", completionID).
Msg("Task completion notification enqueued")
return nil
}