diff --git a/internal/db/db.go b/internal/db/db.go index 48e8d24..31639ed 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -105,10 +105,33 @@ func RunMigrations(ctx context.Context, d *sqlx.DB) error { } func splitSQL(s string) []string { - // Crude but adequate for our migrations (no string-literal semicolons). - // If we ever need to embed semicolons inside strings, switch to a - // proper SQL tokenizer. - return strings.Split(s, ";") + // Comment-aware splitter: skip `;` inside `-- ...` line comments + // and `/* ... */` block comments. Doesn't handle string-literal + // semicolons (we don't put any) — if we ever need that, swap in a + // real SQL tokenizer. + var b strings.Builder + i := 0 + for i < len(s) { + if i+1 < len(s) && s[i] == '-' && s[i+1] == '-' { + // single-line comment — strip through end of line + for i < len(s) && s[i] != '\n' { + i++ + } + continue + } + if i+1 < len(s) && s[i] == '/' && s[i+1] == '*' { + // block comment — strip through `*/` + i += 2 + for i+1 < len(s) && !(s[i] == '*' && s[i+1] == '/') { + i++ + } + i += 2 + continue + } + b.WriteByte(s[i]) + i++ + } + return strings.Split(b.String(), ";") } func firstLine(s string) string { diff --git a/internal/db/migrations/001_init.sql b/internal/db/migrations/001_init.sql index 2ced584..40d2bac 100644 --- a/internal/db/migrations/001_init.sql +++ b/internal/db/migrations/001_init.sql @@ -1,7 +1,7 @@ -- 001_init.sql — Dialectic v2 schema (greenfield, replaces Python v1). -- See /home/hzhang/arch/DIALECTIC-V2-DESIGN.md for the design. --- Verdict schemas — declared at topic-creation time; judge produces output matching. +-- Verdict schemas — declared at topic-creation time, judge produces output matching. CREATE TABLE verdict_schemas ( id VARCHAR(64) NOT NULL PRIMARY KEY, description TEXT NOT NULL, @@ -60,7 +60,7 @@ CREATE TABLE signups ( -- Camps: the post-allocation assignment. One row per (topic, camp) with -- the locked-in agent. Written by camp-allocation algorithm at --- signup_close_at; immutable afterwards (no drop-out / replacement in v1). +-- signup_close_at — immutable afterwards (no drop-out / replacement in v1). CREATE TABLE camps ( id CHAR(36) NOT NULL PRIMARY KEY, topic_id CHAR(36) NOT NULL, @@ -73,7 +73,7 @@ CREATE TABLE camps ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Rounds: chronological partition of arguments. Each topic has N rounds --- (typically 3-5); round 0 is the opening. Round transitions are driven +-- (typically 3-5), round 0 is the opening. Round transitions are driven -- by the orchestrator on a schedule (or all-participants-posted). CREATE TABLE rounds ( id CHAR(36) NOT NULL PRIMARY KEY, @@ -86,7 +86,7 @@ CREATE TABLE rounds ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Arguments: an individual contribution within a round by a camp's agent. --- For pro/con these are claims/rebuttals; for judge these are clarifying +-- For pro/con these are claims/rebuttals — for judge these are clarifying -- questions (judge is silent observer in v1 except for clarifications). CREATE TABLE arguments ( id CHAR(36) NOT NULL PRIMARY KEY, @@ -118,7 +118,7 @@ CREATE TABLE verdicts ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Agent API keys: provisioned per agent at recruitment time. Stored as --- sha256(pepper || raw); pepper rotation invalidates all keys. +-- sha256(pepper || raw) — pepper rotation invalidates all keys. CREATE TABLE agent_keys ( agent_id VARCHAR(64) NOT NULL PRIMARY KEY, key_hash CHAR(64) NOT NULL UNIQUE, diff --git a/internal/httpapi/handlers/topics.go b/internal/httpapi/handlers/topics.go index 4ce7a7e..f7c14c3 100644 --- a/internal/httpapi/handlers/topics.go +++ b/internal/httpapi/handlers/topics.go @@ -114,7 +114,8 @@ func (h *TopicsHandler) Create(w http.ResponseWriter, r *http.Request) { if body.VerdictSchemaID == "" { body.VerdictSchemaID = "free-form" } - if err := validateLifecycleTimes(body); err != nil { + parsed, err := validateLifecycleTimes(body) + if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -123,10 +124,10 @@ func (h *TopicsHandler) Create(w http.ResponseWriter, r *http.Request) { Summary: body.Summary, Visibility: models.Visibility(body.Visibility), VerdictSchemaID: body.VerdictSchemaID, - SignupOpenAt: body.SignupOpenAt, - SignupCloseAt: body.SignupCloseAt, - DebateStartAt: body.DebateStartAt, - DebateEndAt: body.DebateEndAt, + SignupOpenAt: parsed[0], + SignupCloseAt: parsed[1], + DebateStartAt: parsed[2], + DebateEndAt: parsed[3], CreatorUserID: caller.ID, }) if err != nil { @@ -141,7 +142,10 @@ func (h *TopicsHandler) Create(w http.ResponseWriter, r *http.Request) { // signup_open < signup_close <= debate_start < debate_end // // All four timestamps must be parsable as RFC3339; failure → 400. -func validateLifecycleTimes(b createTopicBody) error { +// Returns the parsed times in order [signup_open, signup_close, debate_start, debate_end] +// so the caller can pass typed values to the store (MySQL TIMESTAMP doesn't +// accept ISO8601 strings directly; the driver handles time.Time properly). +func validateLifecycleTimes(b createTopicBody) ([4]time.Time, error) { type p struct { name string raw string @@ -152,24 +156,24 @@ func validateLifecycleTimes(b createTopicBody) error { {"debate_start_at", b.DebateStartAt}, {"debate_end_at", b.DebateEndAt}, } - parsed := make([]time.Time, 4) + var parsed [4]time.Time for i, x := range parts { t, err := time.Parse(time.RFC3339, x.raw) if err != nil { - return errors.New(x.name + ": must be RFC3339") + return parsed, errors.New(x.name + ": must be RFC3339") } - parsed[i] = t + parsed[i] = t.UTC() } if !parsed[0].Before(parsed[1]) { - return errors.New("signup_open_at must be before signup_close_at") + return parsed, errors.New("signup_open_at must be before signup_close_at") } if parsed[1].After(parsed[2]) { - return errors.New("signup_close_at must be <= debate_start_at") + return parsed, errors.New("signup_close_at must be <= debate_start_at") } if !parsed[2].Before(parsed[3]) { - return errors.New("debate_start_at must be before debate_end_at") + return parsed, errors.New("debate_start_at must be before debate_end_at") } - return nil + return parsed, nil } // PUT /api/topics/{id}/visibility — admin-only flip (Phase 2 stub: any diff --git a/internal/store/topic_store.go b/internal/store/topic_store.go index d883911..766d291 100644 --- a/internal/store/topic_store.go +++ b/internal/store/topic_store.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" @@ -26,10 +27,10 @@ type CreateTopicInput struct { Summary string Visibility models.Visibility VerdictSchemaID string - SignupOpenAt string // RFC3339; parsed by SQL - SignupCloseAt string - DebateStartAt string - DebateEndAt string + SignupOpenAt time.Time + SignupCloseAt time.Time + DebateStartAt time.Time + DebateEndAt time.Time CreatorUserID string }