Implement Issue #2: Scheduling & Publishing #10
@@ -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
|
||||||
|
|
||||||
|
|||||||
12
Taskfile.yml
12
Taskfile.yml
@@ -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 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user