From a88e75a8c427d741aa74a6556656691ef7ae7231 Mon Sep 17 00:00:00 2001 From: jbergner Date: Tue, 11 Aug 2026 22:18:08 +0200 Subject: [PATCH] RC-10-A --- README.md | 2 ++ V4.2.6.1_BUILD_FIX.md | 19 +++++++++++ internal/data/schema.sql | 17 ++++++++++ internal/data/store.go | 69 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 V4.2.6.1_BUILD_FIX.md diff --git a/README.md b/README.md index bb19e4e..df9391b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +> **V4.2.6.1 Build Fix:** Das erste V4.2.6-Release-Archiv enthielt versehentlich nicht `internal/data/`. Dadurch konnte neuer Server-Code mit einem alten Data-Package kombiniert werden und der Server-Build scheiterte mit fehlenden `HostedCreditEvent`/Outbox-Methoden. Dieses Archiv enthält den vollständigen Data-Layer. Details: `V4.2.6.1_BUILD_FIX.md`. + > **V4.2.6 Customer Engagement + Admin Controls:** Der Hosted Customer Service kann neuen Konten ein konfigurierbares Startguthaben geben und Hosted Workern für echte persönliche Best-Score-Verbesserungen konfigurierbare Bonus-Credits gutschreiben. Das Customer Portal blendet PayPal vollständig aus, wenn PayPal deaktiviert ist, und fasst den Credit-Verlauf kompakt nach Tag/Buchungsart zusammen. Im privaten Customer-Admin gibt es Benutzer sperren/freigeben, Worker-Stop, Login-/Registrierungs-Kill-Switches, Registrierungs-Proof-of-Work und optionale einmalige Invite-Codes. Positive-Tip-Rewards werden über eine persistente Game-Outbox idempotent an den Customer Service zugestellt. Details: `V4.2.6_CUSTOMER_ENGAGEMENT_ADMIN.md`. > **V4.2 Pipeline Dockerfiles:** Server, Customer Service und Worker besitzen jetzt jeweils ein eigenes Dockerfile (`Dockerfile.server`, `Dockerfile.customer-service`, `Dockerfile.worker`). Das bestehende `Dockerfile` baut weiterhin den Server, damit vorhandene Single-Image-Pipelines kompatibel bleiben. `CS_WORKER_IMAGE` zeigt weiterhin direkt auf das veröffentlichte Worker-Image. diff --git a/V4.2.6.1_BUILD_FIX.md b/V4.2.6.1_BUILD_FIX.md new file mode 100644 index 0000000..45f15a4 --- /dev/null +++ b/V4.2.6.1_BUILD_FIX.md @@ -0,0 +1,19 @@ +# V4.2.6.1 build/package fix + +V4.2.6 introduced the hosted positive-tip credit outbox in `internal/server/customer_credit.go`. +The corresponding store implementation and schema changes are in: + +- `internal/data/store.go` +- `internal/data/schema.sql` + +The first V4.2.6 release archive accidentally omitted the complete `internal/data/` directory while being packaged. When that archive was copied over an older checkout, the new server code was combined with the older data package. The compiler then reported missing symbols such as: + +- `data.HostedCreditEvent` +- `Store.PendingHostedCreditEvents` +- `Store.MarkHostedCreditEventAttempt` +- `Store.MarkHostedCreditEventDelivered` +- `Store.RecordHostedPositiveCreditEvent` + +V4.2.6.1 contains the complete `internal/data/` directory and therefore keeps the server and data package at the same version. + +No game, billing, worker, or portal behavior was intentionally changed by this patch. diff --git a/internal/data/schema.sql b/internal/data/schema.sql index 029f036..8386384 100644 --- a/internal/data/schema.sql +++ b/internal/data/schema.sql @@ -173,3 +173,20 @@ CREATE TABLE IF NOT EXISTS customer_link_tokens ( created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS customer_link_tokens_exp_idx ON customer_link_tokens(expires_at); + +-- Durable outbox for hosted-worker positive-tip rewards. A "positive tip" is +-- a signed guess that improves that worker's personal best score. Delivery to +-- Customer Service is retried and the event id is idempotent there. +CREATE TABLE IF NOT EXISTS hosted_credit_events ( + event_id TEXT PRIMARY KEY, + worker_client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + reward_client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + score REAL NOT NULL, + created_at INTEGER NOT NULL, + delivered_at INTEGER, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS hosted_credit_events_pending_idx ON hosted_credit_events(delivered_at,created_at); diff --git a/internal/data/store.go b/internal/data/store.go index 34c174e..69e6e26 100644 --- a/internal/data/store.go +++ b/internal/data/store.go @@ -1784,3 +1784,72 @@ func (s *Store) ConsumeCustomerLinkToken(ctx context.Context, tokenHash string) } return clientID, nil } + +// HostedCreditEvent is a durable outbox item for customer-service engagement +// credits. Keeping it in the game database means a temporary Customer Service +// outage does not silently lose a positive-tip reward. +type HostedCreditEvent struct { + EventID string `json:"event_id"` + WorkerClientID string `json:"worker_client_id"` + RewardClientID string `json:"reward_client_id"` + TaskID string `json:"task_id"` + Seq int64 `json:"seq"` + Score float64 `json:"score"` + CreatedAt time.Time `json:"created_at"` + Attempts int `json:"attempts"` +} + +func (s *Store) RecordHostedPositiveCreditEvent(ctx context.Context, taskID, workerClientID, rewardClientID string, seq int64, score float64) (string, bool, error) { + workerClientID = strings.TrimSpace(workerClientID) + rewardClientID = strings.TrimSpace(rewardClientID) + if workerClientID == "" || rewardClientID == "" || rewardClientID == workerClientID { + return "", false, nil + } + // The identity delegation itself is the server-side proof that this is a + // hosted/delegated worker rather than an ordinary browser/CLI identity. + var owner string + if err := s.DB.QueryRowContext(ctx, `SELECT owner_client_id FROM identity_delegations WHERE worker_client_id=?`, workerClientID).Scan(&owner); err != nil || owner != rewardClientID { + return "", false, nil + } + eventID := fmt.Sprintf("%s:%s:%d", taskID, workerClientID, seq) + res, err := s.DB.ExecContext(ctx, `INSERT OR IGNORE INTO hosted_credit_events(event_id,worker_client_id,reward_client_id,task_id,seq,score,created_at) VALUES(?,?,?,?,?,?,?)`, eventID, workerClientID, rewardClientID, taskID, seq, score, time.Now().UTC().UnixMilli()) + if err != nil { + return "", false, err + } + n, err := res.RowsAffected() + return eventID, n == 1, err +} + +func (s *Store) PendingHostedCreditEvents(ctx context.Context, limit int) ([]HostedCreditEvent, error) { + if limit < 1 || limit > 200 { + limit = 50 + } + rows, err := s.DB.QueryContext(ctx, `SELECT event_id,worker_client_id,reward_client_id,task_id,seq,score,created_at,attempts FROM hosted_credit_events WHERE delivered_at IS NULL ORDER BY created_at LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []HostedCreditEvent + for rows.Next() { + var x HostedCreditEvent + var created int64 + if err := rows.Scan(&x.EventID, &x.WorkerClientID, &x.RewardClientID, &x.TaskID, &x.Seq, &x.Score, &created, &x.Attempts); err != nil { + return nil, err + } + x.CreatedAt = time.UnixMilli(created).UTC() + out = append(out, x) + } + return out, rows.Err() +} + +func (s *Store) MarkHostedCreditEventDelivered(ctx context.Context, eventID string) error { + _, err := s.DB.ExecContext(ctx, `UPDATE hosted_credit_events SET delivered_at=?,last_error='' WHERE event_id=?`, time.Now().UTC().UnixMilli(), eventID) + return err +} +func (s *Store) MarkHostedCreditEventAttempt(ctx context.Context, eventID, lastErr string) error { + if len(lastErr) > 1000 { + lastErr = lastErr[:1000] + } + _, err := s.DB.ExecContext(ctx, `UPDATE hosted_credit_events SET attempts=attempts+1,last_error=? WHERE event_id=?`, lastErr, eventID) + return err +}