'integer', 'date' => 'date:Y-m-d', 'submitted_by' => 'integer', 'published_by' => 'integer', 'reopened_by' => 'integer', 'submitted_at' => 'datetime', 'published_at' => 'datetime', 'auto_publish_at' => 'datetime', 'reopened_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; /* ============================================================ * Helpers * ============================================================ */ /** * Legacy helper kept for compatibility. * True iff this (section, date, term) is hard-locked for everyone. * Treats 'published' (new) and 'finalized' (old) as finalized. */ public static function isFinalized( int $classSectionId, string $date, ?string $semester = null, ?string $schoolYear = null ): bool { $q = static::query() ->select('id') ->where('class_section_id', $classSectionId) ->whereDate('date', $date) ->where(function (Builder $w) { $w->where('status', 'published') ->orWhere('status', 'finalized'); // tolerate legacy data }); if ($semester !== null) $q->where('semester', $semester); if ($schoolYear !== null) $q->where('school_year', $schoolYear); return $q->exists(); } /** * Find a day row by (section, date, term). Returns model|null. */ public static function findBySectionDate( int $classSectionId, string $date, ?string $semester, ?string $schoolYear ): ?self { return static::query() ->where([ 'class_section_id' => $classSectionId, 'date' => $date, 'semester' => $semester, 'school_year' => $schoolYear, ])->first(); } /** * Get the draft row for (section,date,term), creating it if missing. * Relies on the unique key (class_section_id, date, semester, school_year) to prevent duplicates. */ public static function getOrCreateDraft( int $classSectionId, string $date, ?string $semester, ?string $schoolYear, int $userId = 0 ): self { $now = CarbonImmutable::now('UTC'); // firstOrCreate is already race-safe with a DB unique index: // if two requests race, one insert wins; the other re-queries. return static::query()->firstOrCreate( [ 'class_section_id' => $classSectionId, 'date' => $date, 'semester' => $semester, 'school_year' => $schoolYear, ], [ 'status' => 'draft', 'submitted_by' => null, 'submitted_at' => null, 'published_by' => null, 'published_at' => null, 'auto_publish_at' => null, 'reopened_by' => null, 'reopened_at' => null, 'reopen_reason' => null, 'created_at' => $now, 'updated_at' => $now, ] ); } /** * DEPRECATED: Prior code used "finalize" to lock immediately. * In the new workflow, "finalize" means "teacher submit". */ public function finalize(int $userId): bool { return $this->submit($userId, null); } /** * Teacher submit: status => submitted (teacher locked, admin editable). * Optionally set auto_publish_at. */ public function submit(int $userId, ?string $autoPublishAt = null): bool { $now = CarbonImmutable::now('UTC'); $this->fill([ 'status' => 'submitted', 'submitted_by' => $userId, 'submitted_at' => $now, 'auto_publish_at' => $autoPublishAt, 'updated_at' => $now, ]); return $this->save(); } /** * Admin publish (hard lock): status => published. */ public function publish(int $userId): bool { $now = CarbonImmutable::now('UTC'); $this->fill([ 'status' => 'published', 'published_by' => $userId, 'published_at' => $now, 'updated_at' => $now, ]); return $this->save(); } /** * Admin reopen a submitted/published day back to 'draft' (default) or 'submitted'. */ public function reopen(int $userId, string $toStatus = 'draft', ?string $reason = null): bool { if (!in_array($toStatus, ['draft', 'submitted'], true)) { throw new \InvalidArgumentException('toStatus must be "draft" or "submitted".'); } $now = CarbonImmutable::now('UTC'); $this->fill([ 'status' => $toStatus, 'reopened_by' => $userId, 'reopened_at' => $now, 'reopen_reason' => $reason, 'updated_at' => $now, ]); return $this->save(); } }