Implement Issue #2: Scheduling & Publishing #10

Merged
thatguygriff merged 10 commits from feature/issue-2-scheduling into main 2026-04-08 23:02:18 +00:00
7 changed files with 34 additions and 29 deletions
Showing only changes of commit e82a39f2e4 - Show all commits

View File

@@ -2,7 +2,7 @@
FROM node:22-alpine AS frontend FROM node:22-alpine AS frontend
WORKDIR /app/web WORKDIR /app/web
COPY web/package*.json ./ COPY web/package*.json ./
RUN npm ci RUN npm install
COPY web/ ./ COPY web/ ./
RUN npm run build RUN npm run build

View File

@@ -104,16 +104,16 @@ tasks:
cmd: docker build -t walkies . cmd: docker build -t walkies .
docker:up: docker:up:
desc: Build and start with docker-compose desc: Build and start with docker compose
cmd: docker-compose up --build cmd: docker compose up --build
docker:down: docker:down:
desc: Stop docker-compose services desc: Stop docker compose services
cmd: docker-compose down cmd: docker compose down
docker:logs: docker:logs:
desc: Tail docker-compose logs desc: Tail docker compose logs
cmd: docker-compose logs -f cmd: docker compose logs -f
# ── Utilities ──────────────────────────────────────────────────────────────── # ── Utilities ────────────────────────────────────────────────────────────────

View File

@@ -3,9 +3,10 @@ package db
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
_ "github.com/go-sql-driver/mysql" "github.com/go-sql-driver/mysql"
) )
func Open(dsn string) (*sql.DB, error) { func Open(dsn string) (*sql.DB, error) {
@@ -22,6 +23,10 @@ func Open(dsn string) (*sql.DB, error) {
func Migrate(ctx context.Context, db *sql.DB) error { func Migrate(ctx context.Context, db *sql.DB) error {
for _, stmt := range statements { for _, stmt := range statements {
if _, err := db.ExecContext(ctx, stmt); err != nil { if _, err := db.ExecContext(ctx, stmt); err != nil {
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1060 {
continue // duplicate column — already exists
}
return fmt.Errorf("migrate: %w", err) return fmt.Errorf("migrate: %w", err)
} }
} }

View File

@@ -10,7 +10,7 @@ var statements = []string{
active TINYINT NOT NULL DEFAULT 1, active TINYINT NOT NULL DEFAULT 1,
is_trainee TINYINT NOT NULL DEFAULT 0, is_trainee TINYINT NOT NULL DEFAULT 0,
phone VARCHAR(20) NULL, phone VARCHAR(20) NULL,
operational_roles TEXT NOT NULL DEFAULT '', operational_roles TEXT NOT NULL,
notification_preference VARCHAR(50) NOT NULL DEFAULT 'email', notification_preference VARCHAR(50) NOT NULL DEFAULT 'email',
admin_notes TEXT NULL, admin_notes TEXT NULL,
last_login DATETIME NULL, last_login DATETIME NULL,
@@ -19,15 +19,15 @@ var statements = []string{
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
// Additive column migrations for existing deployments // Additive column migrations for existing deployments (duplicates ignored at runtime)
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS is_trainee TINYINT NOT NULL DEFAULT 0`, `ALTER TABLE volunteers ADD COLUMN is_trainee TINYINT NOT NULL DEFAULT 0`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS phone VARCHAR(20) NULL`, `ALTER TABLE volunteers ADD COLUMN phone VARCHAR(20) NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS operational_roles TEXT NOT NULL DEFAULT ''`, `ALTER TABLE volunteers ADD COLUMN operational_roles TEXT NOT NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS notification_preference VARCHAR(50) NOT NULL DEFAULT 'email'`, `ALTER TABLE volunteers ADD COLUMN notification_preference VARCHAR(50) NOT NULL DEFAULT 'email'`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS admin_notes TEXT NULL`, `ALTER TABLE volunteers ADD COLUMN admin_notes TEXT NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS last_login DATETIME NULL`, `ALTER TABLE volunteers ADD COLUMN last_login DATETIME NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS invite_token VARCHAR(255) NULL`, `ALTER TABLE volunteers ADD COLUMN invite_token VARCHAR(255) NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS invite_expires_at DATETIME NULL`, `ALTER TABLE volunteers ADD COLUMN invite_expires_at DATETIME NULL`,
`CREATE TABLE IF NOT EXISTS schedules ( `CREATE TABLE IF NOT EXISTS schedules (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
volunteer_id INT NOT NULL, volunteer_id INT NOT NULL,
@@ -72,11 +72,11 @@ var statements = []string{
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
volunteer_id INT NOT NULL, volunteer_id INT NOT NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
read TINYINT NOT NULL DEFAULT 0, is_read TINYINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE, FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
INDEX idx_volunteer_id (volunteer_id), INDEX idx_volunteer_id (volunteer_id),
INDEX idx_read (read) INDEX idx_is_read (is_read)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS shift_templates ( `CREATE TABLE IF NOT EXISTS shift_templates (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,

View File

@@ -14,7 +14,7 @@ type Notification struct {
ID int64 `json:"id"` ID int64 `json:"id"`
VolunteerID int64 `json:"volunteer_id"` VolunteerID int64 `json:"volunteer_id"`
Message string `json:"message"` Message string `json:"message"`
Read bool `json:"read"` Read bool `json:"is_read"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -42,7 +42,7 @@ func (s *Store) GetByID(ctx context.Context, id int64) (*Notification, error) {
n := &Notification{} n := &Notification{}
var createdAt string var createdAt string
err := s.db.QueryRowContext(ctx, err := s.db.QueryRowContext(ctx,
`SELECT id, volunteer_id, message, read, created_at FROM notifications WHERE id = ?`, id, `SELECT id, volunteer_id, message, is_read, created_at FROM notifications WHERE id = ?`, id,
).Scan(&n.ID, &n.VolunteerID, &n.Message, &n.Read, &createdAt) ).Scan(&n.ID, &n.VolunteerID, &n.Message, &n.Read, &createdAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
@@ -56,7 +56,7 @@ func (s *Store) GetByID(ctx context.Context, id int64) (*Notification, error) {
func (s *Store) ListForVolunteer(ctx context.Context, volunteerID int64) ([]Notification, error) { func (s *Store) ListForVolunteer(ctx context.Context, volunteerID int64) ([]Notification, error) {
rows, err := s.db.QueryContext(ctx, rows, err := s.db.QueryContext(ctx,
`SELECT id, volunteer_id, message, read, created_at FROM notifications WHERE volunteer_id = ? ORDER BY created_at DESC`, `SELECT id, volunteer_id, message, is_read, created_at FROM notifications WHERE volunteer_id = ? ORDER BY created_at DESC`,
volunteerID, volunteerID,
) )
if err != nil { if err != nil {
@@ -85,7 +85,7 @@ func (s *Store) CreateNotification(ctx context.Context, volunteerID int64, messa
func (s *Store) MarkRead(ctx context.Context, id, volunteerID int64) (*Notification, error) { func (s *Store) MarkRead(ctx context.Context, id, volunteerID int64) (*Notification, error) {
result, err := s.db.ExecContext(ctx, result, err := s.db.ExecContext(ctx,
`UPDATE notifications SET read = 1 WHERE id = ? AND volunteer_id = ?`, `UPDATE notifications SET is_read = 1 WHERE id = ? AND volunteer_id = ?`,
id, volunteerID, id, volunteerID,
) )
if err != nil { if err != nil {

View File

@@ -213,7 +213,7 @@ export interface Notification {
id: number; id: number;
volunteer_id: number; volunteer_id: number;
message: string; message: string;
read: boolean; is_read: boolean;
created_at: string; created_at: string;
} }

View File

@@ -44,7 +44,7 @@ export default function Dashboard() {
async function handleMarkRead(id: number) { async function handleMarkRead(id: number) {
try { try {
await api.markRead(id); await api.markRead(id);
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n)); setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n));
} catch {} } catch {}
} }
@@ -52,7 +52,7 @@ export default function Dashboard() {
.filter(s => new Date(s.date) >= now) .filter(s => new Date(s.date) >= now)
.slice(0, 5); .slice(0, 5);
const unreadNotifications = notifications.filter(n => !n.read); const unreadNotifications = notifications.filter(n => !n.is_read);
return ( return (
<div className="page"> <div className="page">
@@ -93,9 +93,9 @@ export default function Dashboard() {
) : ( ) : (
<ul> <ul>
{notifications.map(n => ( {notifications.map(n => (
<li key={n.id} className={n.read ? 'read' : 'unread'}> <li key={n.id} className={n.is_read ? 'read' : 'unread'}>
{n.message} {n.message}
{!n.read && ( {!n.is_read && (
<button className="btn-small" onClick={() => handleMarkRead(n.id)}>Mark read</button> <button className="btn-small" onClick={() => handleMarkRead(n.id)}>Mark read</button>
)} )}
</li> </li>