Compare commits
13 Commits
3d3ba84df0
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 225fb1306b | |||
| b54493f785 | |||
| 3b2ea9959a | |||
| cf054959bd | |||
| 12de5a230a | |||
| 25897e913e | |||
| 81e454d86d | |||
| 7b87f2e392 | |||
| 6de90acef7 | |||
| 64c656bde1 | |||
| d74cfeee62 | |||
| 52bf1ff3c7 | |||
| e448ec66dc |
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
]),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 mó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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "その他"
|
||||
}
|
||||
|
||||
@@ -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": "기타"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 mó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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¬ifs)
|
||||
db.Where("user_id = ?", payload.UserID).Find(¬ifPrefs)
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user