feat(backend): implement task creation and admin web
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
|
||||
sqlite3 "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const timestampLayout = time.RFC3339Nano
|
||||
|
||||
type queryRower interface {
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(...any) error
|
||||
}
|
||||
|
||||
func scanAsset(scanner rowScanner) (domain.Asset, error) {
|
||||
var asset domain.Asset
|
||||
var createdAt string
|
||||
err := scanner.Scan(
|
||||
&asset.ID,
|
||||
&asset.CreatorSubject,
|
||||
&asset.Purpose,
|
||||
&asset.MediaType,
|
||||
&asset.SizeBytes,
|
||||
&asset.SHA256,
|
||||
&asset.StorageKey,
|
||||
&createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.Asset{}, err
|
||||
}
|
||||
asset.CreatedAt, err = parseTimestamp(createdAt)
|
||||
if err != nil {
|
||||
return domain.Asset{}, err
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func scanTask(scanner rowScanner) (domain.PurchaseTask, error) {
|
||||
var task domain.PurchaseTask
|
||||
var sourceRef sql.NullString
|
||||
var maxBudget sql.NullInt64
|
||||
var cancelReason sql.NullString
|
||||
var canceledAt sql.NullString
|
||||
var createdAt string
|
||||
var updatedAt string
|
||||
err := scanner.Scan(
|
||||
&task.ID,
|
||||
&task.CreatorSubject,
|
||||
&sourceRef,
|
||||
&task.Title,
|
||||
&task.Description,
|
||||
&task.SKU,
|
||||
&task.ImageAssetID,
|
||||
&task.Quantity,
|
||||
&maxBudget,
|
||||
&task.Currency,
|
||||
&task.Status,
|
||||
&task.Version,
|
||||
&cancelReason,
|
||||
&canceledAt,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, err
|
||||
}
|
||||
if sourceRef.Valid {
|
||||
task.SourceRef = &sourceRef.String
|
||||
}
|
||||
if maxBudget.Valid {
|
||||
task.MaxBudgetCents = &maxBudget.Int64
|
||||
}
|
||||
if cancelReason.Valid {
|
||||
task.CancelReason = &cancelReason.String
|
||||
}
|
||||
if canceledAt.Valid {
|
||||
value, err := parseTimestamp(canceledAt.String)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, err
|
||||
}
|
||||
task.CanceledAt = &value
|
||||
}
|
||||
task.CreatedAt, err = parseTimestamp(createdAt)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, err
|
||||
}
|
||||
task.UpdatedAt, err = parseTimestamp(updatedAt)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, err
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func getAssetByID(
|
||||
ctx context.Context,
|
||||
queryer queryRower,
|
||||
creatorSubject string,
|
||||
assetID string,
|
||||
) (domain.Asset, error) {
|
||||
asset, err := scanAsset(queryer.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
id, creator_subject, purpose, media_type, size_bytes,
|
||||
sha256, storage_key, created_at
|
||||
FROM assets
|
||||
WHERE creator_subject = ? AND id = ?`,
|
||||
creatorSubject,
|
||||
assetID,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Asset{}, usecase.ErrRepositoryNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.Asset{}, repositoryFailure(err)
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func getTaskByID(
|
||||
ctx context.Context,
|
||||
queryer queryRower,
|
||||
creatorSubject string,
|
||||
taskID string,
|
||||
) (domain.PurchaseTask, error) {
|
||||
task, err := scanTask(queryer.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
id, creator_subject, source_ref, title, description, sku,
|
||||
image_asset_id, quantity, max_budget_cents, currency, status,
|
||||
version, cancel_reason, canceled_at, created_at, updated_at
|
||||
FROM purchase_tasks
|
||||
WHERE creator_subject = ? AND id = ?`,
|
||||
creatorSubject,
|
||||
taskID,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.PurchaseTask{}, usecase.ErrRepositoryNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, repositoryFailure(err)
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func lookupIdempotency(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
creatorSubject string,
|
||||
operation string,
|
||||
idempotencyKey string,
|
||||
) (requestHash string, resourceID string, found bool, err error) {
|
||||
err = tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT request_sha256, resource_id
|
||||
FROM idempotency_records
|
||||
WHERE creator_subject = ?
|
||||
AND operation = ?
|
||||
AND idempotency_key = ?`,
|
||||
creatorSubject,
|
||||
operation,
|
||||
idempotencyKey,
|
||||
).Scan(&requestHash, &resourceID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", false, repositoryFailure(err)
|
||||
}
|
||||
return requestHash, resourceID, true, nil
|
||||
}
|
||||
|
||||
func insertIdempotency(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
creatorSubject string,
|
||||
operation string,
|
||||
idempotencyKey string,
|
||||
requestHash string,
|
||||
resourceType string,
|
||||
resourceID string,
|
||||
createdAt time.Time,
|
||||
) error {
|
||||
_, err := tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO idempotency_records (
|
||||
creator_subject, operation, idempotency_key, request_sha256,
|
||||
resource_type, resource_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
creatorSubject,
|
||||
operation,
|
||||
idempotencyKey,
|
||||
requestHash,
|
||||
resourceType,
|
||||
resourceID,
|
||||
formatTimestamp(createdAt),
|
||||
)
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatTimestamp(value time.Time) string {
|
||||
return value.UTC().Format(timestampLayout)
|
||||
}
|
||||
|
||||
func parseTimestamp(value string) (time.Time, error) {
|
||||
parsed, err := time.Parse(timestampLayout, value)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf(
|
||||
"%w: invalid stored timestamp",
|
||||
usecase.ErrRepositoryInvariant,
|
||||
)
|
||||
}
|
||||
return parsed.UTC(), nil
|
||||
}
|
||||
|
||||
func nullableString(value *string) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func nullableInt64(value *int64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func repositoryFailure(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var sqliteError sqlite3.Error
|
||||
if errors.As(err, &sqliteError) {
|
||||
switch sqliteError.Code {
|
||||
case sqlite3.ErrBusy, sqlite3.ErrLocked, sqlite3.ErrIoErr,
|
||||
sqlite3.ErrCantOpen, sqlite3.ErrFull:
|
||||
return fmt.Errorf("%w", usecase.ErrRepositoryUnavailable)
|
||||
}
|
||||
}
|
||||
if errors.Is(err, context.Canceled) ||
|
||||
errors.Is(err, context.DeadlineExceeded) {
|
||||
return fmt.Errorf("%w", usecase.ErrRepositoryUnavailable)
|
||||
}
|
||||
return fmt.Errorf("%w", usecase.ErrRepositoryInvariant)
|
||||
}
|
||||
|
||||
func isUniqueConstraint(err error, fragment string) bool {
|
||||
var sqliteError sqlite3.Error
|
||||
if !errors.As(err, &sqliteError) ||
|
||||
sqliteError.ExtendedCode != sqlite3.ErrConstraintUnique {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), fragment)
|
||||
}
|
||||
Reference in New Issue
Block a user