15 Commits

49 changed files with 4164 additions and 651 deletions

27
Caddyfile Normal file
View File

@@ -0,0 +1,27 @@
# Caddyfile - Reverse Proxy Configuration
#
# 使用方法:
# 本番環境で公開する場合は、以下の `:80` を実際のドメイン(例: example.comに変更してください。
# 変更して docker compose up -d を再実行すると、自動的にHTTPS証明書が取得されます。
:80 {
reverse_proxy app:8080
# ログ設定
log {
output file /data/access.log
format json
}
# セキュリティヘッダー
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
X-XSS-Protection "1; mode=block"
Referrer-Policy strict-origin-when-cross-origin
-Server
}
# gzip 圧縮
encode gzip
}

View File

@@ -1,12 +1,8 @@
# Builder stage # Builder stage
FROM golang:1.24-alpine AS builder FROM golang:1.24-trixie AS builder
# Set working directory
WORKDIR /app WORKDIR /app
# Install git if needed for fetching dependencies (sometimes needed even with go modules)
# RUN apk add --no-cache git
# Copy go mod and sum files # Copy go mod and sum files
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -21,12 +17,18 @@ COPY . .
RUN CGO_ENABLED=0 go build -o server ./cmd/server/main.go RUN CGO_ENABLED=0 go build -o server ./cmd/server/main.go
# Runtime stage # Runtime stage
FROM alpine:latest FROM debian:trixie-slim
# Install runtime dependencies # Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
tzdata \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN useradd -r -u 1000 -s /bin/false appuser
# Set working directory
WORKDIR /app WORKDIR /app
# Copy binary from builder # Copy binary from builder
@@ -35,8 +37,21 @@ COPY --from=builder /app/server .
# Copy web assets (templates, static files) # Copy web assets (templates, static files)
COPY --from=builder /app/web ./web COPY --from=builder /app/web ./web
# Expose port (adjust if your app uses a different port) # Create data directory for SQLite
RUN mkdir -p /app/data && chown -R appuser:appuser /app
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 8080 EXPOSE 8080
# Volume for persistent data
VOLUME ["/app/data"]
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/ || exit 1
# Run the application # Run the application
ENTRYPOINT ["./server"] ENTRYPOINT ["./server"]

157
README.md
View File

@@ -1,101 +1,112 @@
# Homework Manager <div align="center">
シンプルな課題管理アプリケーションです。学生の課題管理を効率化するために設計されています。 # Super Homework Manager
シンプルで高機能な課題管理アプリケーション
[![Go](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&logoColor=white)](https://go.dev/)
[![License](https://img.shields.io/badge/License-AGPLv3-blue.svg)](LICENSE.md)
[![Docker](https://img.shields.io/badge/Docker-Compose-2496ED?logo=docker&logoColor=white)](#docker-での実行)
</div>
---
## 概要
学生の課題管理を効率化するために設計されたWebアプリケーションです。
繰り返し課題の自動生成やダッシュボードによる期限管理など、日々の課題管理をサポートします。
## スクリーンショット
| ダッシュボード | 課題一覧 | API |
|:---:|:---:|:---:|
| ![ダッシュボード](./docs/images/dashboard.png) | ![課題一覧](./docs/images/list.png) | ![API](./docs/images/api.png) |
## 特徴 ## 特徴
- **課題管理**: 課題の登録、編集、削除、完了状況の管理 | 機能 | 説明 |
- **ダッシュボード**: 期限切れ、本日期限、今週期限の課題をひと目で確認 |---|---|
- **API対応**: 外部連携用のRESTful API (APIキー認証) | **課題管理** | 課題の登録・編集・削除・完了状況の管理 |
- **セキュリティ**: | **繰り返し課題** | 日次・週次・月次の繰り返し課題を自動生成 |
- CSRF対策 | **ダッシュボード** | 期限切れ・本日期限・今週期限の課題をひと目で確認 |
- レート制限 (Rate Limiting) | **REST API** | 外部連携用のAPIキー認証付きRESTful API |
- セキュアなセッション管理 | **セキュリティ** | CSRF対策 / レート制限 / セキュアなセッション管理 / 2FA対応 |
- **ポータビリティ**: Pure Go SQLiteドライバー使用により、CGO不要でどこでも動作 | **ポータビリティ** | Pure Go SQLiteドライバー使用CGO不要 |
![ダッシュボード](./docs/images/dashboard.png) ## クイックスタート
![課題一覧](./docs/images/list.png)
![API](./docs/images/api.png)
### 前提条件
## TODO - **Docker Desktop** または Docker / Docker Compose
- (ローカルで直接ビルドする場合のみ)**Go 1.24 以上**
- 取り組み目安時間の登録 ### 最も簡単な起動方法
- SNS連携もしかしたらやるかも
## ドキュメント 初めて使う方には、**Dockerを使用した起動**をおすすめします。
詳細な仕様やAPIドキュメントは `docs/` ディレクトリを参照してください 1. このリポジトリをダウンロード(または `git clone`)し、フォルダを開きます
2. フォルダ内にある `config.ini.docker.example` というファイルをコピーし、**名前を `config.ini` に変更**します。
(※ **必須**: この作業を忘れると起動エラーになります)
3. ターミナルまたはコマンドプロンプトやPowerShellでこのフォルダを開き、以下のコマンドを実行します
```bash
docker-compose up -d --build
```
4. ブラウザを開き、**http://localhost** にアクセスしてください。
- [仕様書](docs/SPECIFICATION.md): 機能詳細、データモデル、設定項目 > **注意**: 本番環境(外部公開するサーバー上)で使用する場合は、`Caddyfile` の `:80` を実際のドメインに変更し、`config.ini` 内の `[session] secret` 等の安全な文字列への変更を必ず行ってください。
- [APIドキュメント](docs/API.md): APIのエンドポイント、リクエスト/レスポンス形式
## 前提条件 ### ローカルビルド(開発者向け)
- Go 1.24 以上 開発目的で直接実行する場合の手順です。
## インストール方法 ```bash
# 1. リポジトリのクローン
git clone <repository-url>
cd Homework-Manager
1. **リポジトリのクローン** # 2. 依存関係のダウンロード
```bash go mod download
git clone <repository-url>
cd Homework-Manager
```
2. **依存関係のダウンロード** # 3. ビルド
```bash go build -o homework-manager cmd/server/main.go
go mod download
```
3. **アプリケーションのビルド** # 4. 設定ファイルの準備
```bash cp config.ini.example config.ini
go build -o homework-manager cmd/server/main.go
```
4. **設定ファイルの準備** # 5. 実行
サンプル設定ファイルをコピーして、`config.ini` を作成します。 ./homework-manager
```
```bash
cp config.ini.example config.ini
```
※ Windows (PowerShell): `Copy-Item config.ini.example config.ini`
**重要**: 本番環境で使用する場合は、必ず `[session] secret` と `[security] csrf_secret` を変更してください。 > **Windows (PowerShell)** の場合:
> `Copy-Item config.ini.example config.ini` → `.\homework-manager.exe`
5. **アプリケーションの実行** ブラウザで **http://localhost:8080** にアクセスしてください。
```bash
./homework-manager
```
※ Windows (PowerShell): `.\homework-manager.exe`
ブラウザで `http://localhost:8080` にアクセスしてください。 ## 利用時の注意点
## Dockerでの実行
DockerおよびDocker Composeがインストールされている環境では、以下の手順で簡単に起動できます。
1. **設定ファイルの準備**
```bash
cp config.ini.example config.ini
```
※ 必須です。これを行わないとDockerがディレクトリとして作成してしまい起動に失敗します。
2. **コンテナの起動**
```bash
docker-compose up -d --build
```
3. **アクセスの確認**
ブラウザで `http://localhost:8080` にアクセスしてください。
1人でSuper Homework Managerを利用する場合は、自分のユーザを登録した後にconfigファイルの[auth]セクションのallow_registrationをfalseに変更し再起動してください。
## 更新方法 ## 更新方法
1. `git pull` で最新コードを取得 ```bash
2. `go build -o homework-manager cmd/server/main.go` で再ビルド git pull
3. アプリケーションを再起動 go build -o homework-manager cmd/server/main.go
# アプリケーションを再起動
```
## ドキュメント
| ドキュメント | 内容 |
|---|---|
| [仕様書](docs/SPECIFICATION.md) | 機能詳細・データモデル・設定項目 |
| [APIドキュメント](docs/API.md) | エンドポイント・リクエスト/レスポンス形式 |
## TODO
- [ ] 取り組み目安時間の登録
- [ ] SNS連携
## ライセンス ## ライセンス
本ソフトウェアのライセンスはAGPLv3 (GNU Affero General Public License v3)です。 [AGPLv3 (GNU Affero General Public License v3)](LICENSE.md)
詳しくはLICENSEファイルをご覧ください。

46
config.ini.docker.example Normal file
View File

@@ -0,0 +1,46 @@
; Homework Manager 設定ファイル (Docker用)
; docker-compose.yml と一緒に使用してください
; ローカル実行する場合securityセクションのhttpsをfalseに変更するのを忘れないでください
[server]
port = 8080
debug = false
[database]
driver = mysql
host = db
port = 3306
user = homework
password = homework_password
name = homework_manager
[session]
; 本番環境では必ず変更してください
secret = CHANGE_THIS_TO_A_SECURE_RANDOM_STRING
[auth]
allow_registration = true
[security]
https = false
; こちらも本番環境では必ず変更してください
csrf_secret = CHANGE_THIS_TO_A_SECURE_RANDOM_STRING
rate_limit_enabled = true
rate_limit_requests = 100
rate_limit_window = 60
trusted_proxies = 172.16.0.0/12
[notification]
telegram_bot_token =
[captcha]
; CAPTCHAを有効にするか (true/false)
enabled = false
; CAPTCHAの種類: "image"(自前生成)または "turnstile"Cloudflare Turnstile
type = image
; Cloudflare Turnstileを使用する場合typeをturnstileに設定
; Cloudflare ダッシュボードで取得したサイトキーとシークレットキーを設定
; turnstile_site_key = 0x4AAAAAAAxxxxxxxxxxxxxxxx
; turnstile_secret_key = 0x4AAAAAAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

View File

@@ -58,3 +58,15 @@ rate_limit_window = 60
; Telegram Bot Token (@BotFatherで取得) ; Telegram Bot Token (@BotFatherで取得)
; ユーザーはプロフィール画面でChat IDを設定します ; ユーザーはプロフィール画面でChat IDを設定します
telegram_bot_token = telegram_bot_token =
[captcha]
; CAPTCHAを有効にするか (true/false)
enabled = false
; CAPTCHAの種類: "image"(自前生成)または "turnstile"Cloudflare Turnstile
type = image
; Cloudflare Turnstileを使用する場合typeをturnstileに設定
; Cloudflare ダッシュボードで取得したサイトキーとシークレットキーを設定
; turnstile_site_key = 0x4AAAAAAAxxxxxxxxxxxxxxxx
; turnstile_secret_key = 0x4AAAAAAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

View File

@@ -1,11 +1,62 @@
services: services:
app: app:
build: . build: .
ports: container_name: homework-manager
- "8080:8080"
volumes: volumes:
- ./homework.db:/app/homework.db - ./config.ini:/app/config.ini:ro
- ./config.ini:/app/config.ini
environment: environment:
- TZ=Asia/Tokyo - TZ=Asia/Tokyo
restart: unless-stopped restart: unless-stopped
networks:
- internal
depends_on:
db:
condition: service_healthy
db:
image: mysql:8.0
container_name: homework-db
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword}
MYSQL_DATABASE: ${MYSQL_DATABASE:-homework_manager}
MYSQL_USER: ${MYSQL_USER:-homework}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-homework_password}
TZ: Asia/Tokyo
volumes:
- db-data:/var/lib/mysql
networks:
- internal
restart: unless-stopped
healthcheck:
test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-rootpassword}" ]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
caddy:
image: caddy:2-alpine
container_name: homework-caddy
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
environment:
- TZ=Asia/Tokyo
restart: unless-stopped
networks:
- internal
depends_on:
- app
networks:
internal:
driver: bridge
volumes:
db-data:
caddy-data:
caddy-config:

View File

@@ -23,14 +23,15 @@ Super Homework Manager REST APIは、課題管理機能をプログラムから
### 認証ヘッダー ### 認証ヘッダー
``` ```
X-API-Key: hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX Authorization: Bearer hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
``` ```
### 認証エラー ### 認証エラー
| ステータスコード | レスポンス | | ステータスコード | レスポンス |
|------------------|------------| |------------------|------------|
| 401 Unauthorized | `{"error": "API key required"}` | | 401 Unauthorized | `{"error": "Authorization header required"}` |
| 401 Unauthorized | `{"error": "Invalid authorization format. Use: Bearer <api_key>"}` |
| 401 Unauthorized | `{"error": "Invalid API key"}` | | 401 Unauthorized | `{"error": "Invalid API key"}` |
--- ---
@@ -39,13 +40,22 @@ X-API-Key: hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
| メソッド | パス | 説明 | | メソッド | パス | 説明 |
|----------|------|------| |----------|------|------|
| GET | `/api/v1/assignments` | 課題一覧取得 | | GET | `/api/v1/assignments` | 課題一覧取得(フィルタ・ページネーション対応) |
| GET | `/api/v1/assignments/pending` | 未完了課題一覧取得 |
| GET | `/api/v1/assignments/completed` | 完了済み課題一覧取得 |
| GET | `/api/v1/assignments/overdue` | 期限切れ課題一覧取得 |
| GET | `/api/v1/assignments/due-today` | 本日期限の課題一覧取得 |
| GET | `/api/v1/assignments/due-this-week` | 今週期限の課題一覧取得 |
| GET | `/api/v1/assignments/:id` | 課題詳細取得 | | GET | `/api/v1/assignments/:id` | 課題詳細取得 |
| POST | `/api/v1/assignments` | 課題作成 | | POST | `/api/v1/assignments` | 課題作成 |
| PUT | `/api/v1/assignments/:id` | 課題更新 | | PUT | `/api/v1/assignments/:id` | 課題更新 |
| DELETE | `/api/v1/assignments/:id` | 課題削除 | | DELETE | `/api/v1/assignments/:id` | 課題削除 |
| PATCH | `/api/v1/assignments/:id/toggle` | 完了状態トグル | | PATCH | `/api/v1/assignments/:id/toggle` | 完了状態トグル |
| GET | `/api/v1/statistics` | 統計情報取得 | | GET | `/api/v1/statistics` | 統計情報取得 |
| GET | `/api/v1/recurring` | 繰り返し設定一覧取得 |
| GET | `/api/v1/recurring/:id` | 繰り返し設定詳細取得 |
| PUT | `/api/v1/recurring/:id` | 繰り返し設定更新 |
| DELETE | `/api/v1/recurring/:id` | 繰り返し設定削除 |
--- ---
@@ -59,7 +69,9 @@ GET /api/v1/assignments
| パラメータ | 型 | 説明 | | パラメータ | 型 | 説明 |
|------------|------|------| |------------|------|------|
| `filter` | string | フィルタ: `pending`, `completed`, `overdue` (省略時: 全件) | | `filter` | string | フィルタ: `pending`, `completed`, `overdue`省略時: 全件 |
| `page` | integer | ページ番号(デフォルト: `1` |
| `page_size` | integer | 1ページあたりの件数デフォルト: `20`、最大: `100` |
### レスポンス ### レスポンス
@@ -74,13 +86,18 @@ GET /api/v1/assignments
"title": "数学レポート", "title": "数学レポート",
"description": "第5章の練習問題", "description": "第5章の練習問題",
"subject": "数学", "subject": "数学",
"priority": "medium",
"due_date": "2025-01-15T23:59:00+09:00", "due_date": "2025-01-15T23:59:00+09:00",
"is_completed": false, "is_completed": false,
"created_at": "2025-01-10T10:00:00+09:00", "created_at": "2025-01-10T10:00:00+09:00",
"updated_at": "2025-01-10T10:00:00+09:00" "updated_at": "2025-01-10T10:00:00+09:00"
} }
], ],
"count": 1 "count": 1,
"total_count": 15,
"total_pages": 1,
"current_page": 1,
"page_size": 20
} }
``` ```
@@ -88,13 +105,45 @@ GET /api/v1/assignments
```bash ```bash
# 全件取得 # 全件取得
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments
# 未完了のみ取得 # 未完了のみ(ページネーション付き)
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments?filter=pending curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/assignments?filter=pending&page=1&page_size=10"
# 期限切れのみ取得 # 期限切れのみ
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments?filter=overdue curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/assignments?filter=overdue"
```
---
## 絞り込み済み課題一覧取得
専用エンドポイントでも同等の絞り込みができます(`pending` / `completed` / `overdue` はページネーション対応)。
```
GET /api/v1/assignments/pending
GET /api/v1/assignments/completed
GET /api/v1/assignments/overdue
GET /api/v1/assignments/due-today
GET /api/v1/assignments/due-this-week
```
### クエリパラメータpending / completed / overdue のみ)
| パラメータ | 型 | 説明 |
|------------|------|------|
| `page` | integer | ページ番号(デフォルト: `1` |
| `page_size` | integer | 1ページあたりの件数デフォルト: `20`、最大: `100` |
### レスポンス
`due-today` / `due-this-week``count` のみ返します(ページネーションなし)。その他は `GET /api/v1/assignments` と同形式です。
### 例
```bash
curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments/due-today
curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments/due-this-week
``` ```
--- ---
@@ -122,6 +171,7 @@ GET /api/v1/assignments/:id
"title": "数学レポート", "title": "数学レポート",
"description": "第5章の練習問題", "description": "第5章の練習問題",
"subject": "数学", "subject": "数学",
"priority": "medium",
"due_date": "2025-01-15T23:59:00+09:00", "due_date": "2025-01-15T23:59:00+09:00",
"is_completed": false, "is_completed": false,
"created_at": "2025-01-10T10:00:00+09:00", "created_at": "2025-01-10T10:00:00+09:00",
@@ -140,7 +190,7 @@ GET /api/v1/assignments/:id
### 例 ### 例
```bash ```bash
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments/1 curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments/1
``` ```
--- ---
@@ -158,7 +208,30 @@ POST /api/v1/assignments
| `title` | string | ✅ | 課題タイトル | | `title` | string | ✅ | 課題タイトル |
| `description` | string | | 説明 | | `description` | string | | 説明 |
| `subject` | string | | 教科・科目 | | `subject` | string | | 教科・科目 |
| `priority` | string | | 重要度: `low`, `medium`, `high`(デフォルト: `medium` |
| `due_date` | string | ✅ | 提出期限RFC3339 または `YYYY-MM-DDTHH:MM` または `YYYY-MM-DD` | | `due_date` | string | ✅ | 提出期限RFC3339 または `YYYY-MM-DDTHH:MM` または `YYYY-MM-DD` |
| `reminder_enabled` | boolean | | リマインダーを有効にするか(デフォルト: `false` |
| `reminder_at` | string | | リマインダー設定時刻(形式は `due_date` と同じ) |
| `urgent_reminder_enabled` | boolean | | 督促リマインダーを有効にするか(デフォルト: `true` |
| `recurrence` | object | | 繰り返し設定(下記参照) |
### Recurrence オブジェクト
| フィールド | 型 | 説明 |
|------------|------|------|
| `type` | string | 繰り返しタイプ: `daily`, `weekly`, `monthly`(空文字で繰り返しなし) |
| `interval` | integer | 繰り返し間隔(例: `1` = 毎週、`2` = 隔週) |
| `weekday` | integer | 週次の曜日(`0`=日, `1`=月, ..., `6`=土) |
| `day` | integer | 月次の日付1-31 |
| `until` | object | 終了条件 |
#### Recurrence.Until オブジェクト
| フィールド | 型 | 説明 |
|------------|------|------|
| `type` | string | 終了タイプ: `never`, `count`, `date` |
| `count` | integer | 終了回数(`count` 指定時) |
| `date` | string | 終了日(`date` 指定時) |
### リクエスト例 ### リクエスト例
@@ -167,6 +240,7 @@ POST /api/v1/assignments
"title": "英語エッセイ", "title": "英語エッセイ",
"description": "テーマ自由、1000語以上", "description": "テーマ自由、1000語以上",
"subject": "英語", "subject": "英語",
"priority": "high",
"due_date": "2025-01-20T17:00" "due_date": "2025-01-20T17:00"
} }
``` ```
@@ -182,6 +256,7 @@ POST /api/v1/assignments
"title": "英語エッセイ", "title": "英語エッセイ",
"description": "テーマ自由、1000語以上", "description": "テーマ自由、1000語以上",
"subject": "英語", "subject": "英語",
"priority": "high",
"due_date": "2025-01-20T17:00:00+09:00", "due_date": "2025-01-20T17:00:00+09:00",
"is_completed": false, "is_completed": false,
"created_at": "2025-01-10T11:00:00+09:00", "created_at": "2025-01-10T11:00:00+09:00",
@@ -189,11 +264,20 @@ POST /api/v1/assignments
} }
``` ```
繰り返し設定を含む場合は `recurring_assignment` を返します:
```json
{
"message": "Recurring assignment created",
"recurring_assignment": { ... }
}
```
**400 Bad Request** **400 Bad Request**
```json ```json
{ {
"error": "Invalid input: title and due_date are required" "error": "Invalid input: Key: 'title' Error:Field validation for 'title' failed on the 'required' tag"
} }
``` ```
@@ -201,9 +285,9 @@ POST /api/v1/assignments
```bash ```bash
curl -X POST \ curl -X POST \
-H "X-API-Key: hm_xxx" \ -H "Authorization: Bearer hm_xxx" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"title":"英語エッセイ","due_date":"2025-01-20"}' \ -d '{"title":"英語エッセイ","subject":"英語","due_date":"2025-01-20"}' \
http://localhost:8080/api/v1/assignments http://localhost:8080/api/v1/assignments
``` ```
@@ -230,7 +314,11 @@ PUT /api/v1/assignments/:id
| `title` | string | 課題タイトル | | `title` | string | 課題タイトル |
| `description` | string | 説明 | | `description` | string | 説明 |
| `subject` | string | 教科・科目 | | `subject` | string | 教科・科目 |
| `priority` | string | 重要度: `low`, `medium`, `high` |
| `due_date` | string | 提出期限 | | `due_date` | string | 提出期限 |
| `reminder_enabled` | boolean | リマインダー有効/無効 |
| `reminder_at` | string | リマインダー時刻 |
| `urgent_reminder_enabled` | boolean | 督促リマインダー有効/無効 |
### リクエスト例 ### リクエスト例
@@ -243,21 +331,7 @@ PUT /api/v1/assignments/:id
### レスポンス ### レスポンス
**200 OK** **200 OK** — 更新後の課題オブジェクト
```json
{
"id": 2,
"user_id": 1,
"title": "英語エッセイ(修正版)",
"description": "テーマ自由、1000語以上",
"subject": "英語",
"due_date": "2025-01-25T17:00:00+09:00",
"is_completed": false,
"created_at": "2025-01-10T11:00:00+09:00",
"updated_at": "2025-01-10T12:00:00+09:00"
}
```
**404 Not Found** **404 Not Found**
@@ -271,7 +345,7 @@ PUT /api/v1/assignments/:id
```bash ```bash
curl -X PUT \ curl -X PUT \
-H "X-API-Key: hm_xxx" \ -H "Authorization: Bearer hm_xxx" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"title":"更新されたタイトル"}' \ -d '{"title":"更新されたタイトル"}' \
http://localhost:8080/api/v1/assignments/2 http://localhost:8080/api/v1/assignments/2
@@ -291,30 +365,44 @@ DELETE /api/v1/assignments/:id
|------------|------|------| |------------|------|------|
| `id` | integer | 課題ID | | `id` | integer | 課題ID |
### クエリパラメータ
| パラメータ | 型 | 説明 |
|------------|------|------|
| `delete_recurring` | boolean | `true` の場合、関連する繰り返し設定も削除する |
### レスポンス ### レスポンス
**200 OK** **200 OK**
```json ```json
{ { "message": "Assignment deleted" }
"message": "Assignment deleted" ```
}
繰り返し設定も削除した場合:
```json
{ "message": "Assignment and recurring settings deleted" }
``` ```
**404 Not Found** **404 Not Found**
```json ```json
{ { "error": "Assignment not found" }
"error": "Assignment not found"
}
``` ```
### 例 ### 例
```bash ```bash
# 課題のみ削除
curl -X DELETE \ curl -X DELETE \
-H "X-API-Key: hm_xxx" \ -H "Authorization: Bearer hm_xxx" \
http://localhost:8080/api/v1/assignments/2 http://localhost:8080/api/v1/assignments/2
# 課題と繰り返し設定をまとめて削除
curl -X DELETE \
-H "Authorization: Bearer hm_xxx" \
"http://localhost:8080/api/v1/assignments/2?delete_recurring=true"
``` ```
--- ---
@@ -344,6 +432,7 @@ PATCH /api/v1/assignments/:id/toggle
"title": "数学レポート", "title": "数学レポート",
"description": "第5章の練習問題", "description": "第5章の練習問題",
"subject": "数学", "subject": "数学",
"priority": "medium",
"due_date": "2025-01-15T23:59:00+09:00", "due_date": "2025-01-15T23:59:00+09:00",
"is_completed": true, "is_completed": true,
"completed_at": "2025-01-12T14:30:00+09:00", "completed_at": "2025-01-12T14:30:00+09:00",
@@ -355,16 +444,14 @@ PATCH /api/v1/assignments/:id/toggle
**404 Not Found** **404 Not Found**
```json ```json
{ { "error": "Assignment not found" }
"error": "Assignment not found"
}
``` ```
### 例 ### 例
```bash ```bash
curl -X PATCH \ curl -X PATCH \
-H "X-API-Key: hm_xxx" \ -H "Authorization: Bearer hm_xxx" \
http://localhost:8080/api/v1/assignments/1/toggle http://localhost:8080/api/v1/assignments/1/toggle
``` ```
@@ -372,7 +459,7 @@ curl -X PATCH \
## 統計情報取得 ## 統計情報取得
ユーザーの課題統計を取得します。科目、日付範囲でフィルタリング可能です。 ユーザーの課題統計を取得します。
``` ```
GET /api/v1/statistics GET /api/v1/statistics
@@ -383,8 +470,9 @@ GET /api/v1/statistics
| パラメータ | 型 | 説明 | | パラメータ | 型 | 説明 |
|------------|------|------| |------------|------|------|
| `subject` | string | 科目で絞り込み(省略時: 全科目) | | `subject` | string | 科目で絞り込み(省略時: 全科目) |
| `from` | string | 課題登録日の開始日YYYY-MM-DD形式 | | `from` | string | 課題登録日の開始日(`YYYY-MM-DD` |
| `to` | string | 課題登録日の終了日YYYY-MM-DD形式 | | `to` | string | 課題登録日の終了日(`YYYY-MM-DD` |
| `include_archived` | boolean | アーカイブ済み課題を含む(デフォルト: `false` |
### レスポンス ### レスポンス
@@ -410,50 +498,191 @@ GET /api/v1/statistics
"pending": 2, "pending": 2,
"overdue": 1, "overdue": 1,
"on_time_completion_rate": 91.7 "on_time_completion_rate": 91.7
},
{
"subject": "英語",
"total": 10,
"completed": 8,
"pending": 2,
"overdue": 0,
"on_time_completion_rate": 87.5
} }
] ]
} }
``` ```
### 科目別統計 (特定科目のみ)
```json
{
"total_assignments": 15,
"completed_assignments": 12,
"pending_assignments": 2,
"overdue_assignments": 1,
"on_time_completion_rate": 91.7,
"filter": {
"subject": "数学",
"from": null,
"to": null
}
}
```
### 例 ### 例
```bash ```bash
# 全体統計 # 全体統計
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/statistics curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/statistics
# 科目で絞り込み # 科目で絞り込み
curl -H "X-API-Key: hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数学" curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数学"
# 日付範囲で絞り込み # 日付範囲で絞り込み
curl -H "X-API-Key: hm_xxx" "http://localhost:8080/api/v1/statistics?from=2025-01-01&to=2025-03-31" curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/statistics?from=2025-01-01&to=2025-03-31"
```
# 科目と日付範囲の組み合わせ ---
curl -H "X-API-Key: hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数学&from=2025-01-01&to=2025-03-31"
## 繰り返し設定一覧取得
```
GET /api/v1/recurring
```
### レスポンス
**200 OK**
```json
{
"recurring_assignments": [
{
"id": 1,
"user_id": 1,
"title": "週次ミーティング",
"subject": "その他",
"priority": "medium",
"recurrence_type": "weekly",
"recurrence_interval": 1,
"recurrence_weekday": 1,
"due_time": "23:59",
"end_type": "never",
"is_active": true,
"created_at": "2025-01-01T00:00:00+09:00",
"updated_at": "2025-01-01T00:00:00+09:00"
}
],
"count": 1
}
```
---
## 繰り返し設定詳細取得
```
GET /api/v1/recurring/:id
```
### パスパラメータ
| パラメータ | 型 | 説明 |
|------------|------|------|
| `id` | integer | 繰り返し設定ID |
### レスポンス
**200 OK** — 繰り返し設定オブジェクト(一覧と同形式)
**404 Not Found**
```json
{ "error": "Recurring assignment not found" }
```
---
## 繰り返し設定更新
```
PUT /api/v1/recurring/:id
```
### パスパラメータ
| パラメータ | 型 | 説明 |
|------------|------|------|
| `id` | integer | 繰り返し設定ID |
### リクエストボディ
すべてのフィールドはオプションです。省略されたフィールドは既存の値を維持します。
| フィールド | 型 | 説明 |
|------------|------|------|
| `title` | string | タイトル |
| `description` | string | 説明 |
| `subject` | string | 教科・科目 |
| `priority` | string | 重要度: `low`, `medium`, `high` |
| `recurrence_type` | string | 繰り返しタイプ: `daily`, `weekly`, `monthly` |
| `recurrence_interval` | integer | 繰り返し間隔 |
| `recurrence_weekday` | integer | 週次の曜日0-6 |
| `recurrence_day` | integer | 月次の日付1-31 |
| `due_time` | string | 締切時刻(`HH:MM` |
| `end_type` | string | 終了タイプ: `never`, `count`, `date` |
| `end_count` | integer | 終了回数 |
| `end_date` | string | 終了日(`YYYY-MM-DD` |
| `is_active` | boolean | `false` で停止、`true` で再開 |
| `reminder_enabled` | boolean | リマインダー有効/無効 |
| `reminder_offset` | integer | リマインダーのオフセット(分) |
| `urgent_reminder_enabled` | boolean | 督促リマインダー有効/無効 |
| `edit_behavior` | string | 編集範囲: `this_only`, `this_and_future`, `all`(デフォルト: `this_only` |
### リクエスト例(一時停止)
```json
{
"is_active": false
}
```
### レスポンス
**200 OK** — 更新後の繰り返し設定オブジェクト
**404 Not Found**
```json
{ "error": "Recurring assignment not found" }
```
### 例
```bash
# 一時停止
curl -X PUT \
-H "Authorization: Bearer hm_xxx" \
-H "Content-Type: application/json" \
-d '{"is_active": false}' \
http://localhost:8080/api/v1/recurring/1
# タイトルと締切時刻を変更
curl -X PUT \
-H "Authorization: Bearer hm_xxx" \
-H "Content-Type: application/json" \
-d '{"title":"更新済みタスク","due_time":"22:00"}' \
http://localhost:8080/api/v1/recurring/1
```
---
## 繰り返し設定削除
```
DELETE /api/v1/recurring/:id
```
### パスパラメータ
| パラメータ | 型 | 説明 |
|------------|------|------|
| `id` | integer | 繰り返し設定ID |
### レスポンス
**200 OK**
```json
{ "message": "Recurring assignment deleted" }
```
**404 Not Found**
```json
{ "error": "Recurring assignment not found or failed to delete" }
```
### 例
```bash
curl -X DELETE \
-H "Authorization: Bearer hm_xxx" \
http://localhost:8080/api/v1/recurring/1
``` ```
--- ---
@@ -475,6 +704,7 @@ curl -H "X-API-Key: hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数
| 400 Bad Request | リクエストの形式が不正 | | 400 Bad Request | リクエストの形式が不正 |
| 401 Unauthorized | 認証エラー | | 401 Unauthorized | 認証エラー |
| 404 Not Found | リソースが見つからない | | 404 Not Found | リソースが見つからない |
| 429 Too Many Requests | レート制限超過 |
| 500 Internal Server Error | サーバー内部エラー | | 500 Internal Server Error | サーバー内部エラー |
--- ---
@@ -485,7 +715,7 @@ APIは以下の日付形式を受け付けます優先度順
1. **RFC3339**: `2025-01-15T23:59:00+09:00` 1. **RFC3339**: `2025-01-15T23:59:00+09:00`
2. **日時形式**: `2025-01-15T23:59` 2. **日時形式**: `2025-01-15T23:59`
3. **日付のみ**: `2025-01-15`時刻は23:59に設定 3. **日付のみ**: `2025-01-15`(時刻は `23:59` に設定)
レスポンスの日付はすべてRFC3339形式で返されます。 レスポンスの日付はすべてRFC3339形式で返されます。

View File

@@ -8,9 +8,9 @@ Super Homework Managerは、学生の課題管理を支援するWebアプリケ
| 項目 | 技術 | | 項目 | 技術 |
|------|------| |------|------|
| 言語 | Go | | 言語 | Go 1.24+ |
| Webフレームワーク | Gin | | Webフレームワーク | Gin |
| データベース | SQLite (GORM with Pure Go driver - glebarez/sqlite) | | データベース | SQLite / MySQL / PostgreSQL (GORM、Pure Go SQLiteドライバー使用) |
| セッション管理 | gin-contrib/sessions (Cookie store) | | セッション管理 | gin-contrib/sessions (Cookie store) |
| テンプレートエンジン | Go html/template | | テンプレートエンジン | Go html/template |
| コンテナ | Docker対応 | | コンテナ | Docker対応 |
@@ -27,7 +27,8 @@ homework-manager/
│ ├── middleware/ # ミドルウェア │ ├── middleware/ # ミドルウェア
│ ├── models/ # データモデル │ ├── models/ # データモデル
│ ├── repository/ # データアクセス層 │ ├── repository/ # データアクセス層
── service/ # ビジネスロジック ── service/ # ビジネスロジック
│ └── validation/ # 入力バリデーション
├── web/ ├── web/
│ ├── static/ # 静的ファイル (CSS, JS) │ ├── static/ # 静的ファイル (CSS, JS)
│ └── templates/ # HTMLテンプレート │ └── templates/ # HTMLテンプレート
@@ -50,6 +51,8 @@ homework-manager/
| PasswordHash | string | パスワードハッシュ | Not Null | | PasswordHash | string | パスワードハッシュ | Not Null |
| Name | string | 表示名 | Not Null | | Name | string | 表示名 | Not Null |
| Role | string | 権限 (`user` or `admin`) | Default: `user` | | Role | string | 権限 (`user` or `admin`) | Default: `user` |
| TOTPSecret | string | TOTP秘密鍵 | - |
| TOTPEnabled | bool | 2FA有効フラグ | Default: false |
| CreatedAt | time.Time | 作成日時 | 自動設定 | | CreatedAt | time.Time | 作成日時 | 自動設定 |
| UpdatedAt | time.Time | 更新日時 | 自動更新 | | UpdatedAt | time.Time | 更新日時 | 自動更新 |
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート | | DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
@@ -79,7 +82,33 @@ homework-manager/
| UpdatedAt | time.Time | 更新日時 | 自動更新 | | UpdatedAt | time.Time | 更新日時 | 自動更新 |
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート | | DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
### 2.3 UserNotificationSettings通知設定
### 2.3 RecurringAssignment繰り返し課題
繰り返し課題の設定を管理するモデル。
| フィールド | 型 | 説明 | 制約 |
|------------|------|------|------|
| ID | uint | 設定ID | Primary Key |
| UserID | uint | 所有ユーザーID | Not Null, Index |
| Title | string | 課題タイトル | Not Null |
| Description | string | 説明 | - |
| Subject | string | 教科・科目 | - |
| Priority | string | 重要度 | Default: `medium` |
| RecurrenceType | string | 繰り返しタイプ (`daily`, `weekly`, `monthly`) | Not Null |
| RecurrenceInterval | int | 繰り返し間隔 | Default: 1 |
| RecurrenceWeekday | *int | 曜日 (0-6, 日-土) | Nullable |
| RecurrenceDay | *int | 日 (1-31) | Nullable |
| DueTime | string | 締切時刻 (HH:MM) | Not Null |
| EndType | string | 終了条件 (`never`, `count`, `date`) | Default: `never` |
| EndCount | *int | 終了回数 | Nullable |
| EndDate | *time.Time | 終了日 | Nullable |
| IsActive | bool | 有効フラグ | Default: true |
| CreatedAt | time.Time | 作成日時 | 自動設定 |
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
### 2.4 UserNotificationSettings通知設定
ユーザーの通知設定を管理するモデル。 ユーザーの通知設定を管理するモデル。
@@ -89,13 +118,12 @@ homework-manager/
| UserID | uint | ユーザーID | Unique, Not Null | | UserID | uint | ユーザーID | Unique, Not Null |
| TelegramEnabled | bool | Telegram通知 | Default: false | | TelegramEnabled | bool | Telegram通知 | Default: false |
| TelegramChatID | string | Telegram Chat ID | - | | TelegramChatID | string | Telegram Chat ID | - |
| LineEnabled | bool | LINE通知 | Default: false | | NotifyOnCreate | bool | 課題追加時に通知 | Default: true |
| LineNotifyToken | string | LINE Notifyトークン | - |
| CreatedAt | time.Time | 作成日時 | 自動設定 | | CreatedAt | time.Time | 作成日時 | 自動設定 |
| UpdatedAt | time.Time | 更新日時 | 自動更新 | | UpdatedAt | time.Time | 更新日時 | 自動更新 |
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート | | DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
### 2.4 APIKeyAPIキー ### 2.5 APIKeyAPIキー
REST API認証用のAPIキーを管理するモデル。 REST API認証用のAPIキーを管理するモデル。
@@ -120,10 +148,11 @@ REST API認証用のAPIキーを管理するモデル。
- **パスワード要件**: 8文字以上 - **パスワード要件**: 8文字以上
- **パスワードハッシュ**: bcryptを使用 - **パスワードハッシュ**: bcryptを使用
- **CSRF対策**: 全フォームでのトークン検証 - **CSRF対策**: 全フォームでのトークン検証
- **2段階認証 (TOTP)**: プロフィール画面からGoogle Authenticator等で設定可能。有効化後はログイン時にワンタイムパスワードの入力が必要
### 3.2 API認証 ### 3.2 API認証
- **APIキー認証**: `X-API-Key` ヘッダーで認証 - **APIキー認証**: `Authorization: Bearer <API_KEY>` ヘッダーで認証
- **キー形式**: `hm_` プレフィックス + 32文字のランダム文字列 - **キー形式**: `hm_` プレフィックス + 32文字のランダム文字列
- **ハッシュ保存**: SHA-256でハッシュ化して保存 - **ハッシュ保存**: SHA-256でハッシュ化して保存
@@ -133,6 +162,7 @@ REST API認証用のAPIキーを管理するモデル。
|--------|------| |--------|------|
| `user` | 自分の課題のCRUD操作、プロフィール管理 | | `user` | 自分の課題のCRUD操作、プロフィール管理 |
| `admin` | 全ユーザー管理、APIキー管理、ユーザー権限の変更 | | `admin` | 全ユーザー管理、APIキー管理、ユーザー権限の変更 |
※ 最初に登録されたユーザーには自動的に `admin` 権限が付与されます。2人目以降は `user` として登録されます。 ※ 最初に登録されたユーザーには自動的に `admin` 権限が付与されます。2人目以降は `user` として登録されます。
--- ---
@@ -144,33 +174,47 @@ REST API認証用のAPIキーを管理するモデル。
| 機能 | 説明 | | 機能 | 説明 |
|------|------| |------|------|
| 新規登録 | メールアドレス、パスワード、名前で登録 | | 新規登録 | メールアドレス、パスワード、名前で登録 |
| ログイン | メールアドレスとパスワードでログイン | | ログイン | メールアドレスとパスワードでログイン。2FA有効時は続けてTOTPコードを入力 |
| ログアウト | セッションをクリアしてログアウト | | ログアウト | セッションをクリアしてログアウト |
| CAPTCHA | ログイン・登録フォームへのbot対策画像認証またはCloudflare Turnstile |
### 4.2 課題管理機能 ### 4.2 課題管理機能
| 機能 | 説明 | | 機能 | 説明 |
|------|------| |------|------|
| ダッシュボード | 課題の統計情報、本日期限の課題、期限切れ課題、今週期限の課題を表示 | | ダッシュボード | 課題の統計情報、本日期限の課題、期限切れ課題、今週期限の課題を表示。各統計カードをクリックすると対応するフィルタで課題一覧に遷移 |
| 課題一覧 | フィルタ付き(未完了/完了済み/期限切れ)で課題を一覧表示 | | 課題一覧 | フィルタ付き(未完了/今日が期限/今週が期限/完了済み/期限切れ)で課題を一覧表示 |
| 課題登録 | タイトル、説明、教科、重要度、提出期限、通知設定を入力して新規登録 | | 課題登録 | タイトル、説明、教科、重要度、提出期限、通知設定を入力して新規登録 |
| 課題編集 | 既存の課題情報を編集 | | 課題編集 | 既存の課題情報を編集 |
| 課題削除 | 課題を論理削除 | | 課題削除 | 課題を論理削除(繰り返し課題に関連する場合、繰り返し設定ごと削除するか選択可能) |
| 完了トグル | 課題の完了/未完了状態を切り替え | | 完了トグル | 課題の完了/未完了状態を切り替え |
| 統計 | 科目別の完了率、期限内完了率等を表示 | | 統計 | 科目別の完了率、期限内完了率等を表示 |
### 4.3 通知機能 ### 4.3 繰り返し課題機能
#### 4.3.1 1回リマインダー 周期的に発生する課題を自動生成する機能。
| 機能 | 説明 |
|------|------|
| 繰り返し作成 | 課題登録時に繰り返し条件(毎日/毎週/毎月)を設定して作成 |
| 自動生成 | 未完了の課題がなくなったタイミングで、設定に基づき次回の課題を自動生成 |
| 繰り返し一覧 | 登録されている繰り返し設定を一覧表示 (`/recurring`) |
| 繰り返し編集 | 繰り返し設定の内容(タイトル、条件、時刻など)を編集 |
| 停止・再開 | 繰り返し設定を一時停止、または停止中の設定を再開 |
| 繰り返し削除 | 繰り返し設定を完全に削除 |
### 4.4 通知機能
#### 4.4.1 1回リマインダー
指定した日時に1回だけ通知を送信する機能。 指定した日時に1回だけ通知を送信する機能。
| 項目 | 説明 | | 項目 | 説明 |
|------|------| |------|------|
| 設定 | 課題登録・編集画面で通知日時を指定 | | 設定 | 課題登録・編集画面で通知日時を指定 |
| 送信 | 指定日時にTelegram/LINEで通知 | | 送信 | 指定日時にTelegramで通知 |
#### 4.3.2 督促通知 #### 4.4.2 督促通知
課題を完了するまで繰り返し通知を送信する機能。デフォルトで有効。 課題を完了するまで繰り返し通知を送信する機能。デフォルトで有効。
@@ -182,23 +226,24 @@ REST API認証用のAPIキーを管理するモデル。
| 重要度「小」 | **60分**ごとに通知 | | 重要度「小」 | **60分**ごとに通知 |
| 停止条件 | 課題の完了ボタンを押すまで継続 | | 停止条件 | 課題の完了ボタンを押すまで継続 |
#### 4.3.3 通知チャンネル #### 4.4.3 通知チャンネル
| チャンネル | 設定方法 | | チャンネル | 設定方法 |
|------------|----------| |------------|----------|
| Telegram | config.iniでBot Token設定、プロフィールでChat ID入力 | | Telegram | config.iniでBot Token設定、プロフィールでChat ID入力 |
| LINE Notify | プロフィールでアクセストークン入力 |
### 4.4 プロフィール機能 ### 4.5 プロフィール機能
| 機能 | 説明 | | 機能 | 説明 |
|------|------| |------|------|
| プロフィール表示 | ユーザー情報を表示 | | プロフィール表示 | ユーザー情報を表示 |
| プロフィール更新 | 表示名を変更 | | プロフィール更新 | 表示名を変更 |
| パスワード変更 | 現在のパスワードを確認後、新しいパスワードに変更 | | パスワード変更 | 現在のパスワードを確認後、新しいパスワードに変更 |
| 通知設定 | Telegram/LINE通知の有効化とトークン設定 | | 通知設定 | Telegram通知の有効化とChat ID設定 |
| 2FA設定 | TOTPアプリGoogle Authenticator等でQRコードをスキャンし2FAを有効化 |
| 2FA無効化 | 有効中の2FAを無効化 |
### 4.4 管理者機能 ### 4.6 管理者機能
| 機能 | 説明 | | 機能 | 説明 |
|------|------| |------|------|
@@ -240,7 +285,14 @@ rate_limit_requests = 100
rate_limit_window = 60 rate_limit_window = 60
[notification] [notification]
telegram_bot_token = your-telegram-bot-token telegram_bot_token = your-telegram-bot-token
[captcha]
enabled = false
type = image
# type = turnstile の場合は以下も設定
# turnstile_site_key = your-site-key
# turnstile_secret_key = your-secret-key
``` ```
### 5.2 設定項目 ### 5.2 設定項目
@@ -249,16 +301,26 @@ rate_limit_window = 60
|------------|------|------|--------------| |------------|------|------|--------------|
| `server` | `port` | サーバーポート | `8080` | | `server` | `port` | サーバーポート | `8080` |
| `server` | `debug` | デバッグモード | `true` | | `server` | `debug` | デバッグモード | `true` |
| `database` | `driver` | DBドライバー (sqlite, mysql, postgres) | `sqlite` | | `database` | `driver` | DBドライバー (`sqlite`, `mysql`, `postgres`) | `sqlite` |
| `database` | `path` | SQLiteファイルパス | `homework.db` | | `database` | `path` | SQLiteファイルパス | `homework.db` |
| `session` | `secret` | セッション暗号化キー | (必須) | | `database` | `host` | DBホスト (MySQL/PostgreSQL) | `localhost` |
| `database` | `port` | DBポート (MySQL/PostgreSQL) | `3306` |
| `database` | `user` | DBユーザー (MySQL/PostgreSQL) | `root` |
| `database` | `password` | DBパスワード (MySQL/PostgreSQL) | - |
| `database` | `name` | DB名 (MySQL/PostgreSQL) | `homework_manager` |
| `session` | `secret` | セッション暗号化キー | **(必須)** |
| `auth` | `allow_registration` | 新規登録許可 | `true` | | `auth` | `allow_registration` | 新規登録許可 | `true` |
| `security` | `https` | HTTPS設定(Secure Cookie) | `false` | | `security` | `https` | HTTPS設定 (Secure Cookie) | `false` |
| `security` | `csrf_secret` | CSRFトークン秘密鍵 | (必須) | | `security` | `csrf_secret` | CSRFトークン秘密鍵 | **(必須)** |
| `security` | `rate_limit_enabled` | レート制限有効化 | `true` | | `security` | `rate_limit_enabled` | レート制限有効化 | `true` |
| `security` | `rate_limit_requests` | 期間あたりの最大リクエスト数 | `100` | | `security` | `rate_limit_requests` | 期間あたりの最大リクエスト数 | `100` |
| `security` | `rate_limit_window` | 期間(秒) | `60` | | `security` | `rate_limit_window` | 期間(秒) | `60` |
| `security` | `trusted_proxies` | 信頼するプロキシ | - |
| `notification` | `telegram_bot_token` | Telegram Bot Token | - | | `notification` | `telegram_bot_token` | Telegram Bot Token | - |
| `captcha` | `enabled` | CAPTCHA有効化 | `false` |
| `captcha` | `type` | CAPTCHAタイプ (`image` or `turnstile`) | `image` |
| `captcha` | `turnstile_site_key` | Cloudflare Turnstile サイトキー | - |
| `captcha` | `turnstile_secret_key` | Cloudflare Turnstile シークレットキー | - |
### 5.3 環境変数 ### 5.3 環境変数
@@ -269,13 +331,22 @@ rate_limit_window = 60
| `PORT` | サーバーポート | | `PORT` | サーバーポート |
| `DATABASE_DRIVER` | データベースドライバー | | `DATABASE_DRIVER` | データベースドライバー |
| `DATABASE_PATH` | SQLiteデータベースファイルパス | | `DATABASE_PATH` | SQLiteデータベースファイルパス |
| `DATABASE_HOST` | DBホスト |
| `DATABASE_PORT` | DBポート |
| `DATABASE_USER` | DBユーザー |
| `DATABASE_PASSWORD` | DBパスワード |
| `DATABASE_NAME` | DB名 |
| `SESSION_SECRET` | セッション暗号化キー | | `SESSION_SECRET` | セッション暗号化キー |
| `CSRF_SECRET` | CSRFトークン秘密鍵 | | `CSRF_SECRET` | CSRFトークン秘密鍵 |
| `GIN_MODE` | `release` でリリースモードdebug=false | | `GIN_MODE` | `release` でリリースモード |
| `ALLOW_REGISTRATION` | 新規登録許可 (true/false) | | `ALLOW_REGISTRATION` | 新規登録許可 (`true`/`false`) |
| `HTTPS` | HTTPSモード (true/false) | | `HTTPS` | HTTPSモード (`true`/`false`) |
| `TRUSTED_PROXIES` | 信頼するプロキシのリスト | | `TRUSTED_PROXIES` | 信頼するプロキシ |
| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | | `TELEGRAM_BOT_TOKEN` | Telegram Bot Token |
| `CAPTCHA_ENABLED` | CAPTCHA有効化 (`true`/`false`) |
| `CAPTCHA_TYPE` | CAPTCHAタイプ (`image`/`turnstile`) |
| `TURNSTILE_SITE_KEY` | Cloudflare Turnstile サイトキー |
| `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile シークレットキー |
### 5.4 設定の優先順位 ### 5.4 設定の優先順位
@@ -290,13 +361,16 @@ rate_limit_window = 60
### 6.1 実装済みセキュリティ機能 ### 6.1 実装済みセキュリティ機能
- **パスワードハッシュ化**: bcryptによるソルト付きハッシュ - **パスワードハッシュ化**: bcryptによるソルト付きハッシュ
- **2段階認証 (TOTP)**: RFC 6238準拠のTOTPによる2FA
- **CAPTCHA**: ログイン・登録時のbot対策画像認証またはCloudflare Turnstile
- **セッションセキュリティ**: HttpOnly Cookie - **セッションセキュリティ**: HttpOnly Cookie
- **入力バリデーション**: 各ハンドラで基本的な入力検証 - **入力バリデーション**: 各ハンドラで基本的な入力検証
- **CSFR対策**: Double Submit Cookieパターンまたは同期トークンによるCSRF保護 - **CSRF対策**: Double Submit Cookieパターンによる全フォーム保護
- **レート制限**: IPベースのリクエスト制限によるDoS対策 - **レート制限**: IPベースのリクエスト制限によるDoS対策
- **論理削除**: データの完全削除を防ぐソフトデリート - **論理削除**: データの完全削除を防ぐソフトデリート
- **権限チェック**: ミドルウェアによるロールベースアクセス制御 - **権限チェック**: ミドルウェアによるロールベースアクセス制御
- **Secure Cookie**: HTTPS設定時のSecure属性付与 - **Secure Cookie**: HTTPS設定時のSecure属性付与
- **セキュリティヘッダー**: X-Content-Type-Options, X-Frame-Options等の設定
### 6.2 推奨される本番環境設定 ### 6.2 推奨される本番環境設定
@@ -304,6 +378,7 @@ rate_limit_window = 60
- HTTPSを有効化し、`HTTPS=true` を設定 - HTTPSを有効化し、`HTTPS=true` を設定
- `GIN_MODE=release` を設定 - `GIN_MODE=release` を設定
- 必要に応じて `TRUSTED_PROXIES` を設定 - 必要に応じて `TRUSTED_PROXIES` を設定
- `CAPTCHA_ENABLED=true` を設定してbot対策を強化
--- ---

3
go.mod
View File

@@ -17,10 +17,12 @@ require (
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dchest/captcha v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
@@ -49,6 +51,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.58.0 // indirect github.com/quic-go/quic-go v0.58.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect

6
go.sum
View File

@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
@@ -11,6 +13,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
@@ -87,6 +91,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug= github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=

View File

@@ -21,6 +21,13 @@ type NotificationConfig struct {
TelegramBotToken string TelegramBotToken string
} }
type CaptchaConfig struct {
Enabled bool
Type string // "turnstile" or "image"
TurnstileSiteKey string
TurnstileSecretKey string
}
type Config struct { type Config struct {
Port string Port string
SessionSecret string SessionSecret string
@@ -34,6 +41,7 @@ type Config struct {
TrustedProxies []string TrustedProxies []string
Database DatabaseConfig Database DatabaseConfig
Notification NotificationConfig Notification NotificationConfig
Captcha CaptchaConfig
} }
func Load(configPath string) *Config { func Load(configPath string) *Config {
@@ -56,6 +64,10 @@ func Load(configPath string) *Config {
Password: "", Password: "",
Name: "homework_manager", Name: "homework_manager",
}, },
Captcha: CaptchaConfig{
Enabled: false,
Type: "image",
},
} }
if configPath == "" { if configPath == "" {
@@ -134,6 +146,21 @@ func Load(configPath string) *Config {
if section.HasKey("telegram_bot_token") { if section.HasKey("telegram_bot_token") {
cfg.Notification.TelegramBotToken = section.Key("telegram_bot_token").String() cfg.Notification.TelegramBotToken = section.Key("telegram_bot_token").String()
} }
// Captcha section
section = iniFile.Section("captcha")
if section.HasKey("enabled") {
cfg.Captcha.Enabled = section.Key("enabled").MustBool(false)
}
if section.HasKey("type") {
cfg.Captcha.Type = section.Key("type").String()
}
if section.HasKey("turnstile_site_key") {
cfg.Captcha.TurnstileSiteKey = section.Key("turnstile_site_key").String()
}
if section.HasKey("turnstile_secret_key") {
cfg.Captcha.TurnstileSecretKey = section.Key("turnstile_secret_key").String()
}
} else { } else {
log.Println("config.ini not found, using environment variables or defaults") log.Println("config.ini not found, using environment variables or defaults")
} }
@@ -183,6 +210,18 @@ func Load(configPath string) *Config {
if telegramToken := os.Getenv("TELEGRAM_BOT_TOKEN"); telegramToken != "" { if telegramToken := os.Getenv("TELEGRAM_BOT_TOKEN"); telegramToken != "" {
cfg.Notification.TelegramBotToken = telegramToken cfg.Notification.TelegramBotToken = telegramToken
} }
if captchaEnabled := os.Getenv("CAPTCHA_ENABLED"); captchaEnabled != "" {
cfg.Captcha.Enabled = captchaEnabled == "true" || captchaEnabled == "1"
}
if captchaType := os.Getenv("CAPTCHA_TYPE"); captchaType != "" {
cfg.Captcha.Type = captchaType
}
if turnstileSiteKey := os.Getenv("TURNSTILE_SITE_KEY"); turnstileSiteKey != "" {
cfg.Captcha.TurnstileSiteKey = turnstileSiteKey
}
if turnstileSecretKey := os.Getenv("TURNSTILE_SECRET_KEY"); turnstileSecretKey != "" {
cfg.Captcha.TurnstileSecretKey = turnstileSecretKey
}
if cfg.SessionSecret == "" { if cfg.SessionSecret == "" {
log.Fatal("FATAL: Session secret is not set. Please set it in config.ini ([session] secret) or via SESSION_SECRET environment variable.") log.Fatal("FATAL: Session secret is not set. Please set it in config.ini ([session] secret) or via SESSION_SECRET environment variable.")

View File

@@ -62,19 +62,13 @@ func Connect(dbConfig config.DatabaseConfig, debug bool) error {
return err return err
} }
// Set connection pool settings
sqlDB, err := db.DB() sqlDB, err := db.DB()
if err != nil { if err != nil {
return err return err
} }
// SetMaxIdleConns sets the maximum number of connections in the idle connection pool.
sqlDB.SetMaxIdleConns(10) sqlDB.SetMaxIdleConns(10)
// SetMaxOpenConns sets the maximum number of open connections to the database.
sqlDB.SetMaxOpenConns(100) sqlDB.SetMaxOpenConns(100)
// SetConnMaxLifetime sets the maximum amount of time a connection may be reused.
sqlDB.SetConnMaxLifetime(time.Hour) sqlDB.SetConnMaxLifetime(time.Hour)
DB = db DB = db
@@ -85,6 +79,7 @@ func Migrate() error {
return DB.AutoMigrate( return DB.AutoMigrate(
&models.User{}, &models.User{},
&models.Assignment{}, &models.Assignment{},
&models.RecurringAssignment{},
&models.APIKey{}, &models.APIKey{},
&models.UserNotificationSettings{}, &models.UserNotificationSettings{},
) )

View File

@@ -7,17 +7,20 @@ import (
"homework-manager/internal/middleware" "homework-manager/internal/middleware"
"homework-manager/internal/service" "homework-manager/internal/service"
"homework-manager/internal/validation"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type APIHandler struct { type APIHandler struct {
assignmentService *service.AssignmentService assignmentService *service.AssignmentService
recurringService *service.RecurringAssignmentService
} }
func NewAPIHandler() *APIHandler { func NewAPIHandler() *APIHandler {
return &APIHandler{ return &APIHandler{
assignmentService: service.NewAssignmentService(), assignmentService: service.NewAssignmentService(),
recurringService: service.NewRecurringAssignmentService(),
} }
} }
@@ -26,17 +29,12 @@ func (h *APIHandler) getUserID(c *gin.Context) uint {
return userID.(uint) return userID.(uint)
} }
// ListAssignments returns all assignments for the authenticated user with pagination
// GET /api/v1/assignments?filter=pending&page=1&page_size=20
func (h *APIHandler) ListAssignments(c *gin.Context) { func (h *APIHandler) ListAssignments(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
filter := c.Query("filter") // pending, completed, overdue filter := c.Query("filter") // pending, completed, overdue
// Parse pagination parameters
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
// Validate pagination parameters
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@@ -44,10 +42,9 @@ func (h *APIHandler) ListAssignments(c *gin.Context) {
pageSize = 20 pageSize = 20
} }
if pageSize > 100 { if pageSize > 100 {
pageSize = 100 // Maximum page size to prevent abuse pageSize = 100
} }
// Use paginated methods for filtered queries
switch filter { switch filter {
case "completed": case "completed":
result, err := h.assignmentService.GetCompletedByUserPaginated(userID, page, pageSize) result, err := h.assignmentService.GetCompletedByUserPaginated(userID, page, pageSize)
@@ -56,12 +53,12 @@ func (h *APIHandler) ListAssignments(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"assignments": result.Assignments, "assignments": result.Assignments,
"count": len(result.Assignments), "count": len(result.Assignments),
"total_count": result.TotalCount, "total_count": result.TotalCount,
"total_pages": result.TotalPages, "total_pages": result.TotalPages,
"current_page": result.CurrentPage, "current_page": result.CurrentPage,
"page_size": result.PageSize, "page_size": result.PageSize,
}) })
return return
case "overdue": case "overdue":
@@ -71,12 +68,12 @@ func (h *APIHandler) ListAssignments(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"assignments": result.Assignments, "assignments": result.Assignments,
"count": len(result.Assignments), "count": len(result.Assignments),
"total_count": result.TotalCount, "total_count": result.TotalCount,
"total_pages": result.TotalPages, "total_pages": result.TotalPages,
"current_page": result.CurrentPage, "current_page": result.CurrentPage,
"page_size": result.PageSize, "page_size": result.PageSize,
}) })
return return
case "pending": case "pending":
@@ -86,23 +83,21 @@ func (h *APIHandler) ListAssignments(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"assignments": result.Assignments, "assignments": result.Assignments,
"count": len(result.Assignments), "count": len(result.Assignments),
"total_count": result.TotalCount, "total_count": result.TotalCount,
"total_pages": result.TotalPages, "total_pages": result.TotalPages,
"current_page": result.CurrentPage, "current_page": result.CurrentPage,
"page_size": result.PageSize, "page_size": result.PageSize,
}) })
return return
default: default:
// For "all" filter, use simple pagination without a dedicated method
assignments, err := h.assignmentService.GetAllByUser(userID) assignments, err := h.assignmentService.GetAllByUser(userID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
return return
} }
// Manual pagination for all assignments
totalCount := len(assignments) totalCount := len(assignments)
totalPages := (totalCount + pageSize - 1) / pageSize totalPages := (totalCount + pageSize - 1) / pageSize
start := (page - 1) * pageSize start := (page - 1) * pageSize
@@ -115,18 +110,16 @@ func (h *APIHandler) ListAssignments(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"assignments": assignments[start:end], "assignments": assignments[start:end],
"count": end - start, "count": end - start,
"total_count": totalCount, "total_count": totalCount,
"total_pages": totalPages, "total_pages": totalPages,
"current_page": page, "current_page": page,
"page_size": pageSize, "page_size": pageSize,
}) })
} }
} }
// ListPendingAssignments returns pending assignments with pagination
// GET /api/v1/assignments/pending?page=1&page_size=20
func (h *APIHandler) ListPendingAssignments(c *gin.Context) { func (h *APIHandler) ListPendingAssignments(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
page, pageSize := h.parsePagination(c) page, pageSize := h.parsePagination(c)
@@ -140,8 +133,6 @@ func (h *APIHandler) ListPendingAssignments(c *gin.Context) {
h.sendPaginatedResponse(c, result) h.sendPaginatedResponse(c, result)
} }
// ListCompletedAssignments returns completed assignments with pagination
// GET /api/v1/assignments/completed?page=1&page_size=20
func (h *APIHandler) ListCompletedAssignments(c *gin.Context) { func (h *APIHandler) ListCompletedAssignments(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
page, pageSize := h.parsePagination(c) page, pageSize := h.parsePagination(c)
@@ -155,8 +146,6 @@ func (h *APIHandler) ListCompletedAssignments(c *gin.Context) {
h.sendPaginatedResponse(c, result) h.sendPaginatedResponse(c, result)
} }
// ListOverdueAssignments returns overdue assignments with pagination
// GET /api/v1/assignments/overdue?page=1&page_size=20
func (h *APIHandler) ListOverdueAssignments(c *gin.Context) { func (h *APIHandler) ListOverdueAssignments(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
page, pageSize := h.parsePagination(c) page, pageSize := h.parsePagination(c)
@@ -170,8 +159,6 @@ func (h *APIHandler) ListOverdueAssignments(c *gin.Context) {
h.sendPaginatedResponse(c, result) h.sendPaginatedResponse(c, result)
} }
// ListDueTodayAssignments returns assignments due today
// GET /api/v1/assignments/due-today
func (h *APIHandler) ListDueTodayAssignments(c *gin.Context) { func (h *APIHandler) ListDueTodayAssignments(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
@@ -187,8 +174,6 @@ func (h *APIHandler) ListDueTodayAssignments(c *gin.Context) {
}) })
} }
// ListDueThisWeekAssignments returns assignments due within this week
// GET /api/v1/assignments/due-this-week
func (h *APIHandler) ListDueThisWeekAssignments(c *gin.Context) { func (h *APIHandler) ListDueThisWeekAssignments(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
@@ -204,7 +189,6 @@ func (h *APIHandler) ListDueThisWeekAssignments(c *gin.Context) {
}) })
} }
// parsePagination extracts and validates pagination parameters
func (h *APIHandler) parsePagination(c *gin.Context) (page int, pageSize int) { func (h *APIHandler) parsePagination(c *gin.Context) (page int, pageSize int) {
page, _ = strconv.Atoi(c.DefaultQuery("page", "1")) page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ = strconv.Atoi(c.DefaultQuery("page_size", "20")) pageSize, _ = strconv.Atoi(c.DefaultQuery("page_size", "20"))
@@ -221,7 +205,6 @@ func (h *APIHandler) parsePagination(c *gin.Context) (page int, pageSize int) {
return page, pageSize return page, pageSize
} }
// sendPaginatedResponse sends a standard paginated JSON response
func (h *APIHandler) sendPaginatedResponse(c *gin.Context, result *service.PaginatedResult) { func (h *APIHandler) sendPaginatedResponse(c *gin.Context, result *service.PaginatedResult) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"assignments": result.Assignments, "assignments": result.Assignments,
@@ -233,8 +216,6 @@ func (h *APIHandler) sendPaginatedResponse(c *gin.Context, result *service.Pagin
}) })
} }
// GetAssignment returns a single assignment by ID
// GET /api/v1/assignments/:id
func (h *APIHandler) GetAssignment(c *gin.Context) { func (h *APIHandler) GetAssignment(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32) id, err := strconv.ParseUint(c.Param("id"), 10, 32)
@@ -252,40 +233,126 @@ func (h *APIHandler) GetAssignment(c *gin.Context) {
c.JSON(http.StatusOK, assignment) c.JSON(http.StatusOK, assignment)
} }
// CreateAssignmentInput represents the JSON input for creating an assignment
type CreateAssignmentInput struct { type CreateAssignmentInput struct {
Title string `json:"title" binding:"required"` Title string `json:"title" binding:"required"`
Description string `json:"description"` Description string `json:"description"`
Subject string `json:"subject"` Subject string `json:"subject"`
Priority string `json:"priority"` // low, medium, high (default: medium) Priority string `json:"priority"`
DueDate string `json:"due_date" binding:"required"` // RFC3339 or 2006-01-02T15:04 DueDate string `json:"due_date" binding:"required"`
ReminderEnabled bool `json:"reminder_enabled"`
ReminderAt string `json:"reminder_at"`
UrgentReminderEnabled *bool `json:"urgent_reminder_enabled"`
Recurrence struct {
Type string `json:"type"`
Interval int `json:"interval"`
Weekday interface{} `json:"weekday"`
Day interface{} `json:"day"`
Until struct {
Type string `json:"type"`
Count int `json:"count"`
Date string `json:"date"`
} `json:"until"`
} `json:"recurrence"`
} }
// CreateAssignment creates a new assignment
// POST /api/v1/assignments
func (h *APIHandler) CreateAssignment(c *gin.Context) { func (h *APIHandler) CreateAssignment(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
var input CreateAssignmentInput var input CreateAssignmentInput
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: title and due_date are required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: " + err.Error()})
return return
} }
dueDate, err := time.Parse(time.RFC3339, input.DueDate) if err := validation.ValidateAssignmentInput(input.Title, input.Description, input.Subject, input.Priority); err != nil {
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
dueDate, err = time.ParseInLocation("2006-01-02T15:04", input.DueDate, time.Local) return
if err != nil {
dueDate, err = time.ParseInLocation("2006-01-02", input.DueDate, time.Local)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format. Use RFC3339 or 2006-01-02T15:04"})
return
}
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
}
} }
assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate, false, nil, true) dueDate, err := parseDateString(input.DueDate)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format. Use RFC3339 or 2006-01-02T15:04"})
return
}
var reminderAt *time.Time
if input.ReminderEnabled && input.ReminderAt != "" {
reminderTime, err := parseDateString(input.ReminderAt)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid reminder_at format"})
return
}
reminderAt = &reminderTime
}
urgentReminder := true
if input.UrgentReminderEnabled != nil {
urgentReminder = *input.UrgentReminderEnabled
}
if input.Recurrence.Type != "" && input.Recurrence.Type != "none" {
serviceInput := service.CreateRecurringAssignmentInput{
Title: input.Title,
Description: input.Description,
Subject: input.Subject,
Priority: input.Priority,
FirstDueDate: dueDate,
DueTime: dueDate.Format("15:04"),
RecurrenceType: input.Recurrence.Type,
RecurrenceInterval: input.Recurrence.Interval,
ReminderEnabled: input.ReminderEnabled,
ReminderOffset: nil,
UrgentReminderEnabled: urgentReminder,
}
if serviceInput.RecurrenceInterval < 1 {
serviceInput.RecurrenceInterval = 1
}
if input.Recurrence.Weekday != nil {
if wd, ok := input.Recurrence.Weekday.(float64); ok {
wdInt := int(wd)
serviceInput.RecurrenceWeekday = &wdInt
}
}
if input.Recurrence.Day != nil {
if d, ok := input.Recurrence.Day.(float64); ok {
dInt := int(d)
serviceInput.RecurrenceDay = &dInt
}
}
serviceInput.EndType = input.Recurrence.Until.Type
if serviceInput.EndType == "" {
serviceInput.EndType = "never"
}
if serviceInput.EndType == "count" {
count := input.Recurrence.Until.Count
serviceInput.EndCount = &count
} else if serviceInput.EndType == "date" && input.Recurrence.Until.Date != "" {
endDate, err := parseDateString(input.Recurrence.Until.Date)
if err == nil {
serviceInput.EndDate = &endDate
}
}
recurring, err := h.recurringService.Create(userID, serviceInput)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create recurring assignment: " + err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Recurring assignment created",
"recurring_assignment": recurring,
})
return
}
assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate, input.ReminderEnabled, reminderAt, urgentReminder)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create assignment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create assignment"})
return return
@@ -294,17 +361,17 @@ func (h *APIHandler) CreateAssignment(c *gin.Context) {
c.JSON(http.StatusCreated, assignment) c.JSON(http.StatusCreated, assignment)
} }
// UpdateAssignmentInput represents the JSON input for updating an assignment
type UpdateAssignmentInput struct { type UpdateAssignmentInput struct {
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Subject string `json:"subject"` Subject string `json:"subject"`
Priority string `json:"priority"` Priority string `json:"priority"`
DueDate string `json:"due_date"` DueDate string `json:"due_date"`
ReminderEnabled *bool `json:"reminder_enabled"`
ReminderAt string `json:"reminder_at"`
UrgentReminderEnabled *bool `json:"urgent_reminder_enabled"`
} }
// UpdateAssignment updates an existing assignment
// PUT /api/v1/assignments/:id
func (h *APIHandler) UpdateAssignment(c *gin.Context) { func (h *APIHandler) UpdateAssignment(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32) id, err := strconv.ParseUint(c.Param("id"), 10, 32)
@@ -313,7 +380,6 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) {
return return
} }
// Get existing assignment
existing, err := h.assignmentService.GetByID(userID, uint(id)) existing, err := h.assignmentService.GetByID(userID, uint(id))
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
@@ -326,14 +392,26 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) {
return return
} }
// Use existing values if not provided if err := validation.ValidateAssignmentInput(input.Title, input.Description, input.Subject, input.Priority); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
title := input.Title title := input.Title
if title == "" { if title == "" {
title = existing.Title title = existing.Title
} }
description := input.Description description := input.Description
if description == "" {
description = existing.Description
}
subject := input.Subject subject := input.Subject
if subject == "" {
subject = existing.Subject
}
priority := input.Priority priority := input.Priority
if priority == "" { if priority == "" {
priority = existing.Priority priority = existing.Priority
@@ -341,18 +419,36 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) {
dueDate := existing.DueDate dueDate := existing.DueDate
if input.DueDate != "" { if input.DueDate != "" {
dueDate, err = time.Parse(time.RFC3339, input.DueDate) parsedDate, err := parseDateString(input.DueDate)
if err != nil { if err != nil {
dueDate, err = time.ParseInLocation("2006-01-02T15:04", input.DueDate, time.Local) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format"})
if err != nil { return
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format"})
return
}
} }
dueDate = parsedDate
} }
// Preserve existing reminder settings for API updates reminderEnabled := existing.ReminderEnabled
assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, existing.ReminderEnabled, existing.ReminderAt, existing.UrgentReminderEnabled) if input.ReminderEnabled != nil {
reminderEnabled = *input.ReminderEnabled
}
reminderAt := existing.ReminderAt
if input.ReminderAt != "" {
parsedReminderAt, err := parseDateString(input.ReminderAt)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid reminder_at format"})
return
}
reminderAt = &parsedReminderAt
} else if input.ReminderEnabled != nil && !*input.ReminderEnabled {
}
urgentReminderEnabled := existing.UrgentReminderEnabled
if input.UrgentReminderEnabled != nil {
urgentReminderEnabled = *input.UrgentReminderEnabled
}
assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update assignment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update assignment"})
return return
@@ -361,8 +457,6 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) {
c.JSON(http.StatusOK, assignment) c.JSON(http.StatusOK, assignment)
} }
// DeleteAssignment deletes an assignment
// DELETE /api/v1/assignments/:id
func (h *APIHandler) DeleteAssignment(c *gin.Context) { func (h *APIHandler) DeleteAssignment(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32) id, err := strconv.ParseUint(c.Param("id"), 10, 32)
@@ -371,6 +465,26 @@ func (h *APIHandler) DeleteAssignment(c *gin.Context) {
return return
} }
deleteRecurring := c.Query("delete_recurring") == "true"
if deleteRecurring {
assignment, err := h.assignmentService.GetByID(userID, uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
return
}
if assignment.RecurringAssignmentID != nil {
if err := h.recurringService.Delete(userID, *assignment.RecurringAssignmentID, false); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete recurring assignment"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Assignment and recurring settings deleted"})
return
}
}
if err := h.assignmentService.Delete(userID, uint(id)); err != nil { if err := h.assignmentService.Delete(userID, uint(id)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
return return
@@ -379,8 +493,6 @@ func (h *APIHandler) DeleteAssignment(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Assignment deleted"}) c.JSON(http.StatusOK, gin.H{"message": "Assignment deleted"})
} }
// ToggleAssignment toggles the completion status of an assignment
// PATCH /api/v1/assignments/:id/toggle
func (h *APIHandler) ToggleAssignment(c *gin.Context) { func (h *APIHandler) ToggleAssignment(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32) id, err := strconv.ParseUint(c.Param("id"), 10, 32)
@@ -398,18 +510,14 @@ func (h *APIHandler) ToggleAssignment(c *gin.Context) {
c.JSON(http.StatusOK, assignment) c.JSON(http.StatusOK, assignment)
} }
// GetStatistics returns statistics for the authenticated user
// GET /api/v1/statistics?subject=数学&from=2025-01-01&to=2025-12-31&include_archived=true
func (h *APIHandler) GetStatistics(c *gin.Context) { func (h *APIHandler) GetStatistics(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
// Parse filter parameters
filter := service.StatisticsFilter{ filter := service.StatisticsFilter{
Subject: c.Query("subject"), Subject: c.Query("subject"),
IncludeArchived: c.Query("include_archived") == "true", IncludeArchived: c.Query("include_archived") == "true",
} }
// Parse from date
if fromStr := c.Query("from"); fromStr != "" { if fromStr := c.Query("from"); fromStr != "" {
fromDate, err := time.ParseInLocation("2006-01-02", fromStr, time.Local) fromDate, err := time.ParseInLocation("2006-01-02", fromStr, time.Local)
if err != nil { if err != nil {
@@ -419,7 +527,6 @@ func (h *APIHandler) GetStatistics(c *gin.Context) {
filter.From = &fromDate filter.From = &fromDate
} }
// Parse to date
if toStr := c.Query("to"); toStr != "" { if toStr := c.Query("to"); toStr != "" {
toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local) toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local)
if err != nil { if err != nil {
@@ -438,3 +545,18 @@ func (h *APIHandler) GetStatistics(c *gin.Context) {
c.JSON(http.StatusOK, stats) c.JSON(http.StatusOK, stats)
} }
func parseDateString(dateStr string) (time.Time, error) {
t, err := time.Parse(time.RFC3339, dateStr)
if err == nil {
return t, nil
}
t, err = time.ParseInLocation("2006-01-02T15:04", dateStr, time.Local)
if err == nil {
return t, nil
}
t, err = time.ParseInLocation("2006-01-02", dateStr, time.Local)
if err == nil {
return t.Add(23*time.Hour + 59*time.Minute), nil
}
return time.Time{}, err
}

View File

@@ -0,0 +1,167 @@
package handler
import (
"net/http"
"strconv"
"homework-manager/internal/middleware"
"homework-manager/internal/models"
"homework-manager/internal/service"
"github.com/gin-gonic/gin"
)
type APIRecurringHandler struct {
recurringService *service.RecurringAssignmentService
}
func NewAPIRecurringHandler() *APIRecurringHandler {
return &APIRecurringHandler{
recurringService: service.NewRecurringAssignmentService(),
}
}
func (h *APIRecurringHandler) getUserID(c *gin.Context) uint {
userID, _ := c.Get(middleware.UserIDKey)
return userID.(uint)
}
func (h *APIRecurringHandler) ListRecurring(c *gin.Context) {
userID := h.getUserID(c)
recurringList, err := h.recurringService.GetAllByUser(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch recurring assignments"})
return
}
c.JSON(http.StatusOK, gin.H{
"recurring_assignments": recurringList,
"count": len(recurringList),
})
}
func (h *APIRecurringHandler) GetRecurring(c *gin.Context) {
userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
recurring, err := h.recurringService.GetByID(userID, uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Recurring assignment not found"})
return
}
c.JSON(http.StatusOK, recurring)
}
type UpdateRecurringAPIInput struct {
Title *string `json:"title"`
Description *string `json:"description"`
Subject *string `json:"subject"`
Priority *string `json:"priority"`
RecurrenceType *string `json:"recurrence_type"`
RecurrenceInterval *int `json:"recurrence_interval"`
RecurrenceWeekday *int `json:"recurrence_weekday"`
RecurrenceDay *int `json:"recurrence_day"`
DueTime *string `json:"due_time"`
EndType *string `json:"end_type"`
EndCount *int `json:"end_count"`
EndDate *string `json:"end_date"` // YYYY-MM-DD
IsActive *bool `json:"is_active"` // To stop/resume
ReminderEnabled *bool `json:"reminder_enabled"`
ReminderOffset *int `json:"reminder_offset"`
UrgentReminderEnabled *bool `json:"urgent_reminder_enabled"`
EditBehavior string `json:"edit_behavior"` // this_only, this_and_future, all (default: this_only)
}
func (h *APIRecurringHandler) UpdateRecurring(c *gin.Context) {
userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
var input UpdateRecurringAPIInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: " + err.Error()})
return
}
existing, err := h.recurringService.GetByID(userID, uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Recurring assignment not found"})
return
}
if input.IsActive != nil {
if err := h.recurringService.SetActive(userID, uint(id), *input.IsActive); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update active status"})
return
}
existing.IsActive = *input.IsActive
}
serviceInput := service.UpdateRecurringInput{
Title: input.Title,
Description: input.Description,
Subject: input.Subject,
Priority: input.Priority,
RecurrenceType: input.RecurrenceType,
RecurrenceInterval: input.RecurrenceInterval,
RecurrenceWeekday: input.RecurrenceWeekday,
RecurrenceDay: input.RecurrenceDay,
DueTime: input.DueTime,
EndType: input.EndType,
EndCount: input.EndCount,
EditBehavior: input.EditBehavior,
ReminderEnabled: input.ReminderEnabled,
ReminderOffset: input.ReminderOffset,
UrgentReminderEnabled: input.UrgentReminderEnabled,
}
if input.EndDate != nil && *input.EndDate != "" {
endDate, err := parseDateString(*input.EndDate)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end_date format"})
return
}
serviceInput.EndDate = &endDate
}
if serviceInput.EditBehavior == "" {
serviceInput.EditBehavior = models.EditBehaviorThisOnly
}
updated, err := h.recurringService.Update(userID, uint(id), serviceInput)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update recurring assignment"})
return
}
updated.IsActive = existing.IsActive
c.JSON(http.StatusOK, updated)
}
func (h *APIRecurringHandler) DeleteRecurring(c *gin.Context) {
userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
err = h.recurringService.Delete(userID, uint(id), false)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Recurring assignment not found or failed to delete"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Recurring assignment deleted"})
}

View File

@@ -9,17 +9,22 @@ import (
"homework-manager/internal/middleware" "homework-manager/internal/middleware"
"homework-manager/internal/models" "homework-manager/internal/models"
"homework-manager/internal/service" "homework-manager/internal/service"
"homework-manager/internal/validation"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type AssignmentHandler struct { type AssignmentHandler struct {
assignmentService *service.AssignmentService assignmentService *service.AssignmentService
notificationService *service.NotificationService
recurringService *service.RecurringAssignmentService
} }
func NewAssignmentHandler() *AssignmentHandler { func NewAssignmentHandler(notificationService *service.NotificationService) *AssignmentHandler {
return &AssignmentHandler{ return &AssignmentHandler{
assignmentService: service.NewAssignmentService(), assignmentService: service.NewAssignmentService(),
notificationService: notificationService,
recurringService: service.NewRecurringAssignmentService(),
} }
} }
@@ -102,11 +107,14 @@ func (h *AssignmentHandler) Index(c *gin.Context) {
func (h *AssignmentHandler) New(c *gin.Context) { func (h *AssignmentHandler) New(c *gin.Context) {
role, _ := c.Get(middleware.UserRoleKey) role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey) name, _ := c.Get(middleware.UserNameKey)
now := time.Now()
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{ RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
"title": "課題登録", "title": "課題登録",
"isAdmin": role == "admin", "isAdmin": role == "admin",
"userName": name, "userName": name,
"currentWeekday": int(now.Weekday()),
"currentDay": now.Day(),
}) })
} }
@@ -119,7 +127,22 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
priority := c.PostForm("priority") priority := c.PostForm("priority")
dueDateStr := c.PostForm("due_date") dueDateStr := c.PostForm("due_date")
// Parse reminder settings if err := validation.ValidateAssignmentInput(title, description, subject, priority); err != nil {
role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey)
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
"title": "課題登録",
"error": err.Error(),
"formTitle": title,
"description": description,
"subject": subject,
"priority": priority,
"isAdmin": role == "admin",
"userName": name,
})
return
}
reminderEnabled := c.PostForm("reminder_enabled") == "on" reminderEnabled := c.PostForm("reminder_enabled") == "on"
reminderAtStr := c.PostForm("reminder_at") reminderAtStr := c.PostForm("reminder_at")
var reminderAt *time.Time var reminderAt *time.Time
@@ -151,21 +174,105 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute) dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
} }
_, err = h.assignmentService.Create(userID, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled) recurrenceType := c.PostForm("recurrence_type")
if err != nil { if recurrenceType != "" && recurrenceType != "none" {
role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey) recurrenceInterval := 1
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{ if v, err := strconv.Atoi(c.PostForm("recurrence_interval")); err == nil && v > 0 {
"title": "課題登録", recurrenceInterval = v
"error": "課題の登録に失敗しました", }
"formTitle": title,
"description": description, var recurrenceWeekday *int
"subject": subject, if wd := c.PostForm("recurrence_weekday"); wd != "" {
"priority": priority, if v, err := strconv.Atoi(wd); err == nil && v >= 0 && v <= 6 {
"isAdmin": role == "admin", recurrenceWeekday = &v
"userName": name, }
}) }
return
var recurrenceDay *int
if d := c.PostForm("recurrence_day"); d != "" {
if v, err := strconv.Atoi(d); err == nil && v >= 1 && v <= 31 {
recurrenceDay = &v
}
}
endType := c.PostForm("end_type")
if endType == "" {
endType = models.EndTypeNever
}
var endCount *int
if ec := c.PostForm("end_count"); ec != "" {
if v, err := strconv.Atoi(ec); err == nil && v > 0 {
endCount = &v
}
}
var endDate *time.Time
if ed := c.PostForm("end_date"); ed != "" {
if v, err := time.ParseInLocation("2006-01-02", ed, time.Local); err == nil {
endDate = &v
}
}
dueTime := dueDate.Format("15:04")
recurringService := service.NewRecurringAssignmentService()
input := service.CreateRecurringAssignmentInput{
Title: title,
Description: description,
Subject: subject,
Priority: priority,
RecurrenceType: recurrenceType,
RecurrenceInterval: recurrenceInterval,
RecurrenceWeekday: recurrenceWeekday,
RecurrenceDay: recurrenceDay,
DueTime: dueTime,
EndType: endType,
EndCount: endCount,
EndDate: endDate,
ReminderEnabled: reminderEnabled,
UrgentReminderEnabled: urgentReminderEnabled,
FirstDueDate: dueDate,
}
_, err = recurringService.Create(userID, input)
if err != nil {
role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey)
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
"title": "課題登録",
"error": "繰り返し課題の登録に失敗しました: " + err.Error(),
"formTitle": title,
"description": description,
"subject": subject,
"priority": priority,
"isAdmin": role == "admin",
"userName": name,
})
return
}
} else {
assignment, err := h.assignmentService.Create(userID, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
if err != nil {
role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey)
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
"title": "課題登録",
"error": "課題の登録に失敗しました",
"formTitle": title,
"description": description,
"subject": subject,
"priority": priority,
"isAdmin": role == "admin",
"userName": name,
})
return
}
if h.notificationService != nil {
go h.notificationService.SendAssignmentCreatedNotification(userID, assignment)
}
} }
c.Redirect(http.StatusFound, "/assignments") c.Redirect(http.StatusFound, "/assignments")
@@ -181,12 +288,18 @@ func (h *AssignmentHandler) Edit(c *gin.Context) {
return return
} }
var recurring *models.RecurringAssignment
if assignment.RecurringAssignmentID != nil {
recurring, _ = h.recurringService.GetByID(userID, *assignment.RecurringAssignmentID)
}
role, _ := c.Get(middleware.UserRoleKey) role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey) name, _ := c.Get(middleware.UserNameKey)
RenderHTML(c, http.StatusOK, "assignments/edit.html", gin.H{ RenderHTML(c, http.StatusOK, "assignments/edit.html", gin.H{
"title": "課題編集", "title": "課題編集",
"assignment": assignment, "assignment": assignment,
"recurring": recurring,
"isAdmin": role == "admin", "isAdmin": role == "admin",
"userName": name, "userName": name,
}) })
@@ -202,7 +315,11 @@ func (h *AssignmentHandler) Update(c *gin.Context) {
priority := c.PostForm("priority") priority := c.PostForm("priority")
dueDateStr := c.PostForm("due_date") dueDateStr := c.PostForm("due_date")
// Parse reminder settings if err := validation.ValidateAssignmentInput(title, description, subject, priority); err != nil {
c.Redirect(http.StatusFound, "/assignments")
return
}
reminderEnabled := c.PostForm("reminder_enabled") == "on" reminderEnabled := c.PostForm("reminder_enabled") == "on"
reminderAtStr := c.PostForm("reminder_at") reminderAtStr := c.PostForm("reminder_at")
var reminderAt *time.Time var reminderAt *time.Time
@@ -249,6 +366,14 @@ func (h *AssignmentHandler) Delete(c *gin.Context) {
userID := h.getUserID(c) userID := h.getUserID(c)
id, _ := strconv.ParseUint(c.Param("id"), 10, 32) id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
deleteRecurringStr := c.Query("stop_recurring")
if deleteRecurringStr != "" {
recurringID, err := strconv.ParseUint(deleteRecurringStr, 10, 32)
if err == nil {
h.recurringService.Delete(userID, uint(recurringID), false)
}
}
h.assignmentService.Delete(userID, uint(id)) h.assignmentService.Delete(userID, uint(id))
c.Redirect(http.StatusFound, "/assignments") c.Redirect(http.StatusFound, "/assignments")
@@ -259,7 +384,6 @@ func (h *AssignmentHandler) Statistics(c *gin.Context) {
role, _ := c.Get(middleware.UserRoleKey) role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey) name, _ := c.Get(middleware.UserNameKey)
// Parse filter parameters
filter := service.StatisticsFilter{ filter := service.StatisticsFilter{
Subject: c.Query("subject"), Subject: c.Query("subject"),
IncludeArchived: c.Query("include_archived") == "true", IncludeArchived: c.Query("include_archived") == "true",
@@ -268,7 +392,6 @@ func (h *AssignmentHandler) Statistics(c *gin.Context) {
fromStr := c.Query("from") fromStr := c.Query("from")
toStr := c.Query("to") toStr := c.Query("to")
// Parse from date
if fromStr != "" { if fromStr != "" {
fromDate, err := time.ParseInLocation("2006-01-02", fromStr, time.Local) fromDate, err := time.ParseInLocation("2006-01-02", fromStr, time.Local)
if err == nil { if err == nil {
@@ -276,7 +399,6 @@ func (h *AssignmentHandler) Statistics(c *gin.Context) {
} }
} }
// Parse to date
if toStr != "" { if toStr != "" {
toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local) toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local)
if err == nil { if err == nil {
@@ -293,11 +415,9 @@ func (h *AssignmentHandler) Statistics(c *gin.Context) {
return return
} }
// Get available subjects for filter dropdown (exclude archived)
subjects, _ := h.assignmentService.GetSubjectsWithArchived(userID, false) subjects, _ := h.assignmentService.GetSubjectsWithArchived(userID, false)
archivedSubjects, _ := h.assignmentService.GetArchivedSubjects(userID) archivedSubjects, _ := h.assignmentService.GetArchivedSubjects(userID)
// Create a map for quick lookup of archived subjects
archivedMap := make(map[string]bool) archivedMap := make(map[string]bool)
for _, s := range archivedSubjects { for _, s := range archivedSubjects {
archivedMap[s] = true archivedMap[s] = true
@@ -339,4 +459,161 @@ func (h *AssignmentHandler) UnarchiveSubject(c *gin.Context) {
c.Redirect(http.StatusFound, "/statistics?include_archived=true") c.Redirect(http.StatusFound, "/statistics?include_archived=true")
} }
func (h *AssignmentHandler) StopRecurring(c *gin.Context) {
userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/assignments")
return
}
h.recurringService.SetActive(userID, uint(id), false)
c.Redirect(http.StatusFound, "/assignments")
}
func (h *AssignmentHandler) ResumeRecurring(c *gin.Context) {
userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/assignments")
return
}
h.recurringService.SetActive(userID, uint(id), true)
referer := c.Request.Referer()
if referer == "" {
referer = "/assignments"
}
c.Redirect(http.StatusFound, referer)
}
func (h *AssignmentHandler) ListRecurring(c *gin.Context) {
userID := h.getUserID(c)
role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey)
recurrings, err := h.recurringService.GetAllByUser(userID)
if err != nil {
recurrings = []models.RecurringAssignment{}
}
RenderHTML(c, http.StatusOK, "recurring/index.html", gin.H{
"title": "繰り返し設定一覧",
"recurrings": recurrings,
"isAdmin": role == "admin",
"userName": name,
})
}
func (h *AssignmentHandler) EditRecurring(c *gin.Context) {
userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/assignments")
return
}
recurring, err := h.recurringService.GetByID(userID, uint(id))
if err != nil {
c.Redirect(http.StatusFound, "/assignments")
return
}
role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey)
RenderHTML(c, http.StatusOK, "recurring/edit.html", gin.H{
"title": "繰り返し課題の編集",
"recurring": recurring,
"isAdmin": role == "admin",
"userName": name,
})
}
func (h *AssignmentHandler) UpdateRecurring(c *gin.Context) {
userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/assignments")
return
}
title := c.PostForm("title")
description := c.PostForm("description")
subject := c.PostForm("subject")
priority := c.PostForm("priority")
recurrenceType := c.PostForm("recurrence_type")
dueTime := c.PostForm("due_time")
editBehavior := c.PostForm("edit_behavior")
recurrenceInterval := 1
if v, err := strconv.Atoi(c.PostForm("recurrence_interval")); err == nil && v > 0 {
recurrenceInterval = v
}
var recurrenceWeekday *int
if wd := c.PostForm("recurrence_weekday"); wd != "" {
if v, err := strconv.Atoi(wd); err == nil && v >= 0 && v <= 6 {
recurrenceWeekday = &v
}
}
var recurrenceDay *int
if d := c.PostForm("recurrence_day"); d != "" {
if v, err := strconv.Atoi(d); err == nil && v >= 1 && v <= 31 {
recurrenceDay = &v
}
}
endType := c.PostForm("end_type")
var endCount *int
if ec := c.PostForm("end_count"); ec != "" {
if v, err := strconv.Atoi(ec); err == nil && v > 0 {
endCount = &v
}
}
var endDate *time.Time
if ed := c.PostForm("end_date"); ed != "" {
if v, err := time.ParseInLocation("2006-01-02", ed, time.Local); err == nil {
endDate = &v
}
}
input := service.UpdateRecurringInput{
Title: &title,
Description: &description,
Subject: &subject,
Priority: &priority,
RecurrenceType: &recurrenceType,
RecurrenceInterval: &recurrenceInterval,
RecurrenceWeekday: recurrenceWeekday,
RecurrenceDay: recurrenceDay,
DueTime: &dueTime,
EndType: &endType,
EndCount: endCount,
EndDate: endDate,
EditBehavior: editBehavior,
}
_, err = h.recurringService.Update(userID, uint(id), input)
if err != nil {
c.Redirect(http.StatusFound, "/recurring/"+c.Param("id")+"/edit")
return
}
c.Redirect(http.StatusFound, "/assignments")
}
func (h *AssignmentHandler) DeleteRecurring(c *gin.Context) {
userID := h.getUserID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.Redirect(http.StatusFound, "/assignments")
return
}
h.recurringService.Delete(userID, uint(id), false)
c.Redirect(http.StatusFound, "/assignments")
}

View File

@@ -3,6 +3,7 @@ package handler
import ( import (
"net/http" "net/http"
"homework-manager/internal/config"
"homework-manager/internal/middleware" "homework-manager/internal/middleware"
"homework-manager/internal/service" "homework-manager/internal/service"
@@ -10,33 +11,96 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
const twoFAPendingKey = "2fa_pending_user_id"
type AuthHandler struct { type AuthHandler struct {
authService *service.AuthService authService *service.AuthService
totpService *service.TOTPService
captchaService *service.CaptchaService
captchaCfg config.CaptchaConfig
} }
func NewAuthHandler() *AuthHandler { func NewAuthHandler(captchaCfg config.CaptchaConfig) *AuthHandler {
captchaSvc := service.NewCaptchaService(captchaCfg.Type, captchaCfg.TurnstileSecretKey)
return &AuthHandler{ return &AuthHandler{
authService: service.NewAuthService(), authService: service.NewAuthService(),
totpService: service.NewTOTPService(),
captchaService: captchaSvc,
captchaCfg: captchaCfg,
} }
} }
func (h *AuthHandler) captchaData() gin.H {
data := gin.H{
"captchaEnabled": h.captchaCfg.Enabled,
"captchaType": h.captchaCfg.Type,
}
if h.captchaCfg.Enabled && h.captchaCfg.Type == "turnstile" {
data["turnstileSiteKey"] = h.captchaCfg.TurnstileSiteKey
}
if h.captchaCfg.Enabled && h.captchaCfg.Type == "image" {
data["captchaID"] = h.captchaService.NewImageCaptcha()
}
return data
}
func (h *AuthHandler) verifyCaptcha(c *gin.Context) bool {
if !h.captchaCfg.Enabled {
return true
}
switch h.captchaCfg.Type {
case "turnstile":
token := c.PostForm("cf-turnstile-response")
ok, err := h.captchaService.VerifyTurnstile(token, c.ClientIP())
return err == nil && ok
case "image":
id := c.PostForm("captcha_id")
answer := c.PostForm("captcha_answer")
return h.captchaService.VerifyImageCaptcha(id, answer)
}
return true
}
func (h *AuthHandler) ShowLogin(c *gin.Context) { func (h *AuthHandler) ShowLogin(c *gin.Context) {
RenderHTML(c, http.StatusOK, "login.html", gin.H{ data := gin.H{"title": "ログイン"}
"title": "ログイン", for k, v := range h.captchaData() {
}) data[k] = v
}
RenderHTML(c, http.StatusOK, "login.html", data)
} }
func (h *AuthHandler) Login(c *gin.Context) { func (h *AuthHandler) Login(c *gin.Context) {
email := c.PostForm("email") email := c.PostForm("email")
password := c.PostForm("password") password := c.PostForm("password")
renderLoginError := func(msg string) {
data := gin.H{
"title": "ログイン",
"error": msg,
"email": email,
}
for k, v := range h.captchaData() {
data[k] = v
}
RenderHTML(c, http.StatusOK, "login.html", data)
}
if !h.verifyCaptcha(c) {
renderLoginError("CAPTCHAの検証に失敗しました。もう一度お試しください")
return
}
user, err := h.authService.Login(email, password) user, err := h.authService.Login(email, password)
if err != nil { if err != nil {
RenderHTML(c, http.StatusOK, "login.html", gin.H{ renderLoginError("メールアドレスまたはパスワードが正しくありません")
"title": "ログイン", return
"error": "メールアドレスまたはパスワードが正しくありません", }
"email": email,
}) if user.TOTPEnabled {
session := sessions.Default(c)
session.Set(twoFAPendingKey, user.ID)
session.Save()
c.Redirect(http.StatusFound, "/login/2fa")
return return
} }
@@ -49,35 +113,91 @@ func (h *AuthHandler) Login(c *gin.Context) {
c.Redirect(http.StatusFound, "/") c.Redirect(http.StatusFound, "/")
} }
func (h *AuthHandler) ShowRegister(c *gin.Context) { func (h *AuthHandler) ShowLogin2FA(c *gin.Context) {
RenderHTML(c, http.StatusOK, "register.html", gin.H{ session := sessions.Default(c)
"title": "新規登録", if session.Get(twoFAPendingKey) == nil {
c.Redirect(http.StatusFound, "/login")
return
}
RenderHTML(c, http.StatusOK, "login_2fa.html", gin.H{
"title": "2段階認証",
}) })
} }
func (h *AuthHandler) Login2FA(c *gin.Context) {
session := sessions.Default(c)
pendingID := session.Get(twoFAPendingKey)
if pendingID == nil {
c.Redirect(http.StatusFound, "/login")
return
}
userID := pendingID.(uint)
user, err := h.authService.GetUserByID(userID)
if err != nil {
session.Delete(twoFAPendingKey)
session.Save()
c.Redirect(http.StatusFound, "/login")
return
}
code := c.PostForm("totp_code")
if !h.totpService.Validate(user.TOTPSecret, code) {
RenderHTML(c, http.StatusOK, "login_2fa.html", gin.H{
"title": "2段階認証",
"error": "認証コードが正しくありません",
})
return
}
session.Delete(twoFAPendingKey)
session.Set(middleware.UserIDKey, user.ID)
session.Set(middleware.UserRoleKey, user.Role)
session.Set(middleware.UserNameKey, user.Name)
session.Save()
c.Redirect(http.StatusFound, "/")
}
func (h *AuthHandler) ShowRegister(c *gin.Context) {
data := gin.H{"title": "新規登録"}
for k, v := range h.captchaData() {
data[k] = v
}
RenderHTML(c, http.StatusOK, "register.html", data)
}
func (h *AuthHandler) Register(c *gin.Context) { func (h *AuthHandler) Register(c *gin.Context) {
email := c.PostForm("email") email := c.PostForm("email")
password := c.PostForm("password") password := c.PostForm("password")
passwordConfirm := c.PostForm("password_confirm") passwordConfirm := c.PostForm("password_confirm")
name := c.PostForm("name") name := c.PostForm("name")
if password != passwordConfirm { renderRegisterError := func(msg string) {
RenderHTML(c, http.StatusOK, "register.html", gin.H{ data := gin.H{
"title": "新規登録", "title": "新規登録",
"error": "パスワードが一致しません", "error": msg,
"email": email, "email": email,
"name": name, "name": name,
}) }
for k, v := range h.captchaData() {
data[k] = v
}
RenderHTML(c, http.StatusOK, "register.html", data)
}
if !h.verifyCaptcha(c) {
renderRegisterError("CAPTCHAの検証に失敗しました。もう一度お試しください")
return
}
if password != passwordConfirm {
renderRegisterError("パスワードが一致しません")
return return
} }
if len(password) < 8 { if len(password) < 8 {
RenderHTML(c, http.StatusOK, "register.html", gin.H{ renderRegisterError("パスワードは8文字以上で入力してください")
"title": "新規登録",
"error": "パスワードは8文字以上で入力してください",
"email": email,
"name": name,
})
return return
} }
@@ -87,12 +207,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
if err == service.ErrEmailAlreadyExists { if err == service.ErrEmailAlreadyExists {
errorMsg = "このメールアドレスは既に使用されています" errorMsg = "このメールアドレスは既に使用されています"
} }
RenderHTML(c, http.StatusOK, "register.html", gin.H{ renderRegisterError(errorMsg)
"title": "新規登録",
"error": errorMsg,
"email": email,
"name": name,
})
return return
} }

View File

@@ -7,18 +7,23 @@ import (
"homework-manager/internal/models" "homework-manager/internal/models"
"homework-manager/internal/service" "homework-manager/internal/service"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type ProfileHandler struct { type ProfileHandler struct {
authService *service.AuthService authService *service.AuthService
totpService *service.TOTPService
notificationService *service.NotificationService notificationService *service.NotificationService
appName string
} }
func NewProfileHandler(notificationService *service.NotificationService) *ProfileHandler { func NewProfileHandler(notificationService *service.NotificationService) *ProfileHandler {
return &ProfileHandler{ return &ProfileHandler{
authService: service.NewAuthService(), authService: service.NewAuthService(),
totpService: service.NewTOTPService(),
notificationService: notificationService, notificationService: notificationService,
appName: "Super-HomeworkManager",
} }
} }
@@ -143,8 +148,7 @@ func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
settings := &models.UserNotificationSettings{ settings := &models.UserNotificationSettings{
TelegramEnabled: c.PostForm("telegram_enabled") == "on", TelegramEnabled: c.PostForm("telegram_enabled") == "on",
TelegramChatID: c.PostForm("telegram_chat_id"), TelegramChatID: c.PostForm("telegram_chat_id"),
LineEnabled: c.PostForm("line_enabled") == "on", NotifyOnCreate: c.PostForm("notify_on_create") == "on",
LineNotifyToken: c.PostForm("line_token"),
} }
err := h.notificationService.UpdateUserSettings(userID, settings) err := h.notificationService.UpdateUserSettings(userID, settings)
@@ -173,3 +177,134 @@ func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
}) })
} }
const totpPendingSecretKey = "totp_pending_secret"
func (h *ProfileHandler) ShowTOTPSetup(c *gin.Context) {
userID := h.getUserID(c)
user, _ := h.authService.GetUserByID(userID)
setupData, err := h.totpService.GenerateSecret(user.Email, h.appName)
if err != nil {
RenderHTML(c, http.StatusOK, "totp_setup.html", gin.H{
"title": "2段階認証の設定",
"error": "シークレットの生成に失敗しました",
})
return
}
session := sessions.Default(c)
session.Set(totpPendingSecretKey, setupData.Secret)
session.Save()
RenderHTML(c, http.StatusOK, "totp_setup.html", gin.H{
"title": "2段階認証の設定",
"secret": setupData.Secret,
"qrCode": setupData.QRCodeB64,
"otpAuthURL": setupData.OTPAuthURL,
})
}
func (h *ProfileHandler) EnableTOTP(c *gin.Context) {
userID := h.getUserID(c)
user, _ := h.authService.GetUserByID(userID)
session := sessions.Default(c)
secret, ok := session.Get(totpPendingSecretKey).(string)
if !ok || secret == "" {
c.Redirect(http.StatusFound, "/profile/totp/setup")
return
}
renderSetupError := func(msg string) {
data := gin.H{
"title": "2段階認証の設定",
"error": msg,
"secret": secret,
}
if setupData, err := h.totpService.SetupDataFromSecret(secret, user.Email, h.appName); err == nil {
data["qrCode"] = setupData.QRCodeB64
data["otpAuthURL"] = setupData.OTPAuthURL
}
RenderHTML(c, http.StatusOK, "totp_setup.html", data)
}
password := c.PostForm("password")
if _, err := h.authService.Login(user.Email, password); err != nil {
renderSetupError("パスワードが正しくありません")
return
}
code := c.PostForm("totp_code")
if !h.totpService.Validate(secret, code) {
renderSetupError("認証コードが正しくありません。もう一度試してください")
return
}
if err := h.authService.EnableTOTP(userID, secret); err != nil {
RenderHTML(c, http.StatusOK, "totp_setup.html", gin.H{
"title": "2段階認証の設定",
"error": "2段階認証の有効化に失敗しました",
})
return
}
session.Delete(totpPendingSecretKey)
session.Save()
role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey)
notifySettings, _ := h.notificationService.GetUserSettings(userID)
user, _ = h.authService.GetUserByID(userID)
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
"title": "プロフィール",
"user": user,
"totpSuccess": "2段階認証を有効化しました",
"isAdmin": role == "admin",
"userName": name,
"notifySettings": notifySettings,
})
}
func (h *ProfileHandler) DisableTOTP(c *gin.Context) {
userID := h.getUserID(c)
role, _ := c.Get(middleware.UserRoleKey)
name, _ := c.Get(middleware.UserNameKey)
user, _ := h.authService.GetUserByID(userID)
notifySettings, _ := h.notificationService.GetUserSettings(userID)
password := c.PostForm("password")
if _, err := h.authService.Login(user.Email, password); err != nil {
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
"title": "プロフィール",
"user": user,
"totpError": "パスワードが正しくありません",
"isAdmin": role == "admin",
"userName": name,
"notifySettings": notifySettings,
})
return
}
if err := h.authService.DisableTOTP(userID); err != nil {
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
"title": "プロフィール",
"user": user,
"totpError": "2段階認証の無効化に失敗しました",
"isAdmin": role == "admin",
"userName": name,
"notifySettings": notifySettings,
})
return
}
user, _ = h.authService.GetUserByID(userID)
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
"title": "プロフィール",
"user": user,
"totpSuccess": "2段階認証を無効化しました",
"isAdmin": role == "admin",
"userName": name,
"notifySettings": notifySettings,
})
}

View File

@@ -101,12 +101,6 @@ func CSRF(config CSRFConfig) gin.HandlerFunc {
} }
c.Next() c.Next()
newToken, err := generateCSRFToken(config.Secret)
if err == nil {
session.Set(csrfTokenKey, newToken)
session.Save()
}
} }
} }

View File

@@ -7,21 +7,33 @@ import (
) )
type SecurityConfig struct { type SecurityConfig struct {
HTTPS bool HTTPS bool
TurnstileEnabled bool
} }
func SecurityHeaders(config SecurityConfig) gin.HandlerFunc { func SecurityHeaders(config SecurityConfig) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if config.HTTPS { if config.HTTPS {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains") c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
} }
scriptSrc := "'self' 'unsafe-inline' https://cdn.jsdelivr.net"
frameSrc := "'none'"
connectSrc := "'self'"
if config.TurnstileEnabled {
scriptSrc += " https://challenges.cloudflare.com"
frameSrc = "https://challenges.cloudflare.com"
connectSrc += " https://challenges.cloudflare.com"
}
csp := []string{ csp := []string{
"default-src 'self'", "default-src 'self'",
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net", "script-src " + scriptSrc,
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net", "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
"font-src 'self' https://cdn.jsdelivr.net", "font-src 'self' https://cdn.jsdelivr.net",
"img-src 'self' data:", "img-src 'self' data:",
"connect-src 'self'", "connect-src " + connectSrc,
"frame-src " + frameSrc,
"frame-ancestors 'none'", "frame-ancestors 'none'",
} }
c.Header("Content-Security-Policy", strings.Join(csp, "; ")) c.Header("Content-Security-Policy", strings.Join(csp, "; "))

View File

@@ -10,7 +10,7 @@ type APIKey struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"` UserID uint `gorm:"not null;index" json:"user_id"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
KeyHash string `gorm:"not null;uniqueIndex" json:"-"` KeyHash string `gorm:"not null;uniqueIndex;size:255" json:"-"`
LastUsed *time.Time `json:"last_used,omitempty"` LastUsed *time.Time `json:"last_used,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`

View File

@@ -7,26 +7,29 @@ import (
) )
type Assignment struct { type Assignment struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"` UserID uint `gorm:"not null;index" json:"user_id"`
Title string `gorm:"not null" json:"title"` Title string `gorm:"not null" json:"title"`
Description string `json:"description"` Description string `json:"description"`
Subject string `json:"subject"` Subject string `json:"subject"`
Priority string `gorm:"not null;default:medium" json:"priority"` // low, medium, high Priority string `gorm:"not null;default:medium" json:"priority"`
DueDate time.Time `gorm:"not null" json:"due_date"` DueDate time.Time `gorm:"not null" json:"due_date"`
IsCompleted bool `gorm:"default:false" json:"is_completed"` IsCompleted bool `gorm:"default:false" json:"is_completed"`
IsArchived bool `gorm:"default:false;index" json:"is_archived"` IsArchived bool `gorm:"default:false;index" json:"is_archived"`
CompletedAt *time.Time `json:"completed_at,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"`
// Reminder notification settings (one-time) ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"`
ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"` ReminderAt *time.Time `json:"reminder_at,omitempty"`
ReminderAt *time.Time `json:"reminder_at,omitempty"` ReminderSent bool `gorm:"default:false;index" json:"reminder_sent"`
ReminderSent bool `gorm:"default:false;index" json:"reminder_sent"` UrgentReminderEnabled bool `gorm:"default:true" json:"urgent_reminder_enabled"`
// Urgent reminder settings (repeating until completed) LastUrgentReminderSent *time.Time `json:"last_urgent_reminder_sent,omitempty"`
UrgentReminderEnabled bool `gorm:"default:true" json:"urgent_reminder_enabled"`
LastUrgentReminderSent *time.Time `json:"last_urgent_reminder_sent,omitempty"` // Recurring assignment reference
CreatedAt time.Time `json:"created_at"` RecurringAssignmentID *uint `gorm:"index" json:"recurring_assignment_id,omitempty"`
UpdatedAt time.Time `json:"updated_at"` RecurringAssignment *RecurringAssignment `gorm:"foreignKey:RecurringAssignmentID" json:"-"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
} }

View File

@@ -6,17 +6,16 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// UserNotificationSettings stores user's notification preferences
type UserNotificationSettings struct { type UserNotificationSettings struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"` UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
TelegramEnabled bool `gorm:"default:false" json:"telegram_enabled"` TelegramEnabled bool `gorm:"default:false" json:"telegram_enabled"`
TelegramChatID string `json:"telegram_chat_id"` TelegramChatID string `json:"telegram_chat_id"`
LineEnabled bool `gorm:"default:false" json:"line_enabled"`
LineNotifyToken string `json:"-"` // Hide token from JSON NotifyOnCreate bool `gorm:"default:true" json:"notify_on_create"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
User *User `gorm:"foreignKey:UserID" json:"-"` User *User `gorm:"foreignKey:UserID" json:"-"`
} }

View File

@@ -0,0 +1,102 @@
package models
import (
"time"
"gorm.io/gorm"
)
const (
RecurrenceNone = "none"
RecurrenceDaily = "daily"
RecurrenceWeekly = "weekly"
RecurrenceMonthly = "monthly"
)
const (
EndTypeNever = "never"
EndTypeCount = "count"
EndTypeDate = "date"
)
const (
EditBehaviorThisOnly = "this_only"
EditBehaviorThisAndFuture = "this_and_future"
EditBehaviorAll = "all"
)
type RecurringAssignment struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Title string `gorm:"not null" json:"title"`
Description string `json:"description"`
Subject string `json:"subject"`
Priority string `gorm:"not null;default:medium" json:"priority"`
RecurrenceType string `gorm:"not null;default:none" json:"recurrence_type"`
RecurrenceInterval int `gorm:"not null;default:1" json:"recurrence_interval"`
RecurrenceWeekday *int `json:"recurrence_weekday,omitempty"`
RecurrenceDay *int `json:"recurrence_day,omitempty"`
DueTime string `gorm:"not null" json:"due_time"`
EndType string `gorm:"not null;default:never" json:"end_type"`
EndCount *int `json:"end_count,omitempty"`
EndDate *time.Time `json:"end_date,omitempty"`
GeneratedCount int `gorm:"default:0" json:"generated_count"`
EditBehavior string `gorm:"not null;default:this_only" json:"edit_behavior"`
ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"`
ReminderOffset *int `json:"reminder_offset,omitempty"`
UrgentReminderEnabled bool `gorm:"default:true" json:"urgent_reminder_enabled"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Assignments []Assignment `gorm:"foreignKey:RecurringAssignmentID" json:"assignments,omitempty"`
}
func (r *RecurringAssignment) ShouldGenerateNext() bool {
if !r.IsActive || r.RecurrenceType == RecurrenceNone {
return false
}
switch r.EndType {
case EndTypeCount:
if r.EndCount != nil && r.GeneratedCount >= *r.EndCount {
return false
}
case EndTypeDate:
if r.EndDate != nil && time.Now().After(*r.EndDate) {
return false
}
}
return true
}
func (r *RecurringAssignment) CalculateNextDueDate(lastDueDate time.Time) time.Time {
var nextDate time.Time
switch r.RecurrenceType {
case RecurrenceDaily:
nextDate = lastDueDate.AddDate(0, 0, r.RecurrenceInterval)
case RecurrenceWeekly:
nextDate = lastDueDate.AddDate(0, 0, 7*r.RecurrenceInterval)
case RecurrenceMonthly:
nextDate = lastDueDate.AddDate(0, r.RecurrenceInterval, 0)
if r.RecurrenceDay != nil {
day := *r.RecurrenceDay
lastDayOfMonth := time.Date(nextDate.Year(), nextDate.Month()+1, 0, 0, 0, 0, 0, nextDate.Location()).Day()
if day > lastDayOfMonth {
day = lastDayOfMonth
}
nextDate = time.Date(nextDate.Year(), nextDate.Month(), day, nextDate.Hour(), nextDate.Minute(), 0, 0, nextDate.Location())
}
default:
return lastDueDate
}
return nextDate
}

View File

@@ -8,10 +8,12 @@ import (
type User struct { type User struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Email string `gorm:"uniqueIndex;not null" json:"email"` Email string `gorm:"uniqueIndex;not null;size:255" json:"email"`
PasswordHash string `gorm:"not null" json:"-"` PasswordHash string `gorm:"not null" json:"-"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Role string `gorm:"not null;default:user" json:"role"` // "admin" or "user" Role string `gorm:"not null;default:user" json:"role"` // "admin" or "user"
TOTPSecret string `gorm:"size:100" json:"-"`
TOTPEnabled bool `gorm:"default:false" json:"totp_enabled"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`

View File

@@ -148,11 +148,19 @@ func (r *AssignmentRepository) Search(userID uint, queryStr, priority, filter st
} }
now := time.Now() now := time.Now()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
endOfDay := startOfDay.AddDate(0, 0, 1)
weekLater := startOfDay.AddDate(0, 0, 7)
switch filter { switch filter {
case "completed": case "completed":
dbQuery = dbQuery.Where("is_completed = ?", true) dbQuery = dbQuery.Where("is_completed = ?", true)
case "overdue": case "overdue":
dbQuery = dbQuery.Where("is_completed = ? AND due_date < ?", false, now) dbQuery = dbQuery.Where("is_completed = ? AND due_date < ?", false, now)
case "due_today":
dbQuery = dbQuery.Where("is_completed = ? AND due_date >= ? AND due_date < ?", false, startOfDay, endOfDay)
case "due_this_week":
dbQuery = dbQuery.Where("is_completed = ? AND due_date >= ? AND due_date < ?", false, startOfDay, weekLater)
default: // pending default: // pending
dbQuery = dbQuery.Where("is_completed = ?", false) dbQuery = dbQuery.Where("is_completed = ?", false)
} }
@@ -187,7 +195,6 @@ func (r *AssignmentRepository) CountOverdueByUserID(userID uint) (int64, error)
return count, err return count, err
} }
// StatisticsFilter holds filter parameters for statistics queries
type StatisticsFilter struct { type StatisticsFilter struct {
Subject string Subject string
From *time.Time From *time.Time
@@ -195,17 +202,15 @@ type StatisticsFilter struct {
IncludeArchived bool IncludeArchived bool
} }
// AssignmentStatistics holds statistics data
type AssignmentStatistics struct { type AssignmentStatistics struct {
Total int64 Total int64
Completed int64 Completed int64
Pending int64 Pending int64
Overdue int64 Overdue int64
CompletedOnTime int64 CompletedOnTime int64
OnTimeCompletionRate float64 OnTimeCompletionRate float64
} }
// SubjectStatistics holds statistics for a specific subject
type SubjectStatistics struct { type SubjectStatistics struct {
Subject string Subject string
Total int64 Total int64
@@ -216,64 +221,48 @@ type SubjectStatistics struct {
OnTimeCompletionRate float64 OnTimeCompletionRate float64
} }
// GetStatistics returns statistics for a user with optional filters
func (r *AssignmentRepository) GetStatistics(userID uint, filter StatisticsFilter) (*AssignmentStatistics, error) { func (r *AssignmentRepository) GetStatistics(userID uint, filter StatisticsFilter) (*AssignmentStatistics, error) {
now := time.Now() now := time.Now()
stats := &AssignmentStatistics{} stats := &AssignmentStatistics{}
// Base query
baseQuery := r.db.Model(&models.Assignment{}).Where("user_id = ?", userID) baseQuery := r.db.Model(&models.Assignment{}).Where("user_id = ?", userID)
// Apply subject filter
if filter.Subject != "" { if filter.Subject != "" {
baseQuery = baseQuery.Where("subject = ?", filter.Subject) baseQuery = baseQuery.Where("subject = ?", filter.Subject)
} }
// Apply date range filter (by created_at)
if filter.From != nil { if filter.From != nil {
baseQuery = baseQuery.Where("created_at >= ?", *filter.From) baseQuery = baseQuery.Where("created_at >= ?", *filter.From)
} }
if filter.To != nil { if filter.To != nil {
// Add 1 day to include the entire "to" date
toEnd := filter.To.AddDate(0, 0, 1) toEnd := filter.To.AddDate(0, 0, 1)
baseQuery = baseQuery.Where("created_at < ?", toEnd) baseQuery = baseQuery.Where("created_at < ?", toEnd)
} }
// Apply archived filter
if !filter.IncludeArchived { if !filter.IncludeArchived {
baseQuery = baseQuery.Where("is_archived = ?", false) baseQuery = baseQuery.Where("is_archived = ?", false)
} }
// Total count
if err := baseQuery.Count(&stats.Total).Error; err != nil { if err := baseQuery.Count(&stats.Total).Error; err != nil {
return nil, err return nil, err
} }
// Completed count
completedQuery := baseQuery.Session(&gorm.Session{}) completedQuery := baseQuery.Session(&gorm.Session{})
if err := completedQuery.Where("is_completed = ?", true).Count(&stats.Completed).Error; err != nil { if err := completedQuery.Where("is_completed = ?", true).Count(&stats.Completed).Error; err != nil {
return nil, err return nil, err
} }
// Pending count
pendingQuery := baseQuery.Session(&gorm.Session{}) pendingQuery := baseQuery.Session(&gorm.Session{})
if err := pendingQuery.Where("is_completed = ?", false).Count(&stats.Pending).Error; err != nil { if err := pendingQuery.Where("is_completed = ?", false).Count(&stats.Pending).Error; err != nil {
return nil, err return nil, err
} }
// Overdue count
overdueQuery := baseQuery.Session(&gorm.Session{}) overdueQuery := baseQuery.Session(&gorm.Session{})
if err := overdueQuery.Where("is_completed = ? AND due_date < ?", false, now).Count(&stats.Overdue).Error; err != nil { if err := overdueQuery.Where("is_completed = ? AND due_date < ?", false, now).Count(&stats.Overdue).Error; err != nil {
return nil, err return nil, err
} }
// Completed on time count (completed_at <= due_date)
onTimeQuery := baseQuery.Session(&gorm.Session{}) onTimeQuery := baseQuery.Session(&gorm.Session{})
if err := onTimeQuery.Where("is_completed = ? AND completed_at <= due_date", true).Count(&stats.CompletedOnTime).Error; err != nil { if err := onTimeQuery.Where("is_completed = ? AND completed_at <= due_date", true).Count(&stats.CompletedOnTime).Error; err != nil {
return nil, err return nil, err
} }
// Calculate on-time completion rate
if stats.Completed > 0 { if stats.Completed > 0 {
stats.OnTimeCompletionRate = float64(stats.CompletedOnTime) / float64(stats.Completed) * 100 stats.OnTimeCompletionRate = float64(stats.CompletedOnTime) / float64(stats.Completed) * 100
} }
@@ -281,7 +270,6 @@ func (r *AssignmentRepository) GetStatistics(userID uint, filter StatisticsFilte
return stats, nil return stats, nil
} }
// GetStatisticsBySubjects returns statistics grouped by subject
func (r *AssignmentRepository) GetStatisticsBySubjects(userID uint, filter StatisticsFilter) ([]SubjectStatistics, error) { func (r *AssignmentRepository) GetStatisticsBySubjects(userID uint, filter StatisticsFilter) ([]SubjectStatistics, error) {
now := time.Now() now := time.Now()
subjects, err := r.GetSubjectsByUserID(userID) subjects, err := r.GetSubjectsByUserID(userID)
@@ -301,7 +289,6 @@ func (r *AssignmentRepository) GetStatisticsBySubjects(userID uint, filter Stati
return nil, err return nil, err
} }
// Count overdue for this subject
overdueQuery := r.db.Model(&models.Assignment{}). overdueQuery := r.db.Model(&models.Assignment{}).
Where("user_id = ? AND subject = ? AND is_completed = ? AND due_date < ?", userID, subject, false, now) Where("user_id = ? AND subject = ? AND is_completed = ? AND due_date < ?", userID, subject, false, now)
if filter.From != nil { if filter.From != nil {
@@ -328,21 +315,18 @@ func (r *AssignmentRepository) GetStatisticsBySubjects(userID uint, filter Stati
return results, nil return results, nil
} }
// ArchiveBySubject archives all assignments for a subject
func (r *AssignmentRepository) ArchiveBySubject(userID uint, subject string) error { func (r *AssignmentRepository) ArchiveBySubject(userID uint, subject string) error {
return r.db.Model(&models.Assignment{}). return r.db.Model(&models.Assignment{}).
Where("user_id = ? AND subject = ?", userID, subject). Where("user_id = ? AND subject = ?", userID, subject).
Update("is_archived", true).Error Update("is_archived", true).Error
} }
// UnarchiveBySubject unarchives all assignments for a subject
func (r *AssignmentRepository) UnarchiveBySubject(userID uint, subject string) error { func (r *AssignmentRepository) UnarchiveBySubject(userID uint, subject string) error {
return r.db.Model(&models.Assignment{}). return r.db.Model(&models.Assignment{}).
Where("user_id = ? AND subject = ?", userID, subject). Where("user_id = ? AND subject = ?", userID, subject).
Update("is_archived", false).Error Update("is_archived", false).Error
} }
// GetArchivedSubjects returns a list of archived subjects for a user
func (r *AssignmentRepository) GetArchivedSubjects(userID uint) ([]string, error) { func (r *AssignmentRepository) GetArchivedSubjects(userID uint) ([]string, error) {
var subjects []string var subjects []string
err := r.db.Model(&models.Assignment{}). err := r.db.Model(&models.Assignment{}).
@@ -352,7 +336,6 @@ func (r *AssignmentRepository) GetArchivedSubjects(userID uint) ([]string, error
return subjects, err return subjects, err
} }
// GetSubjectsByUserIDWithArchived returns subjects optionally including archived
func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, includeArchived bool) ([]string, error) { func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, includeArchived bool) ([]string, error) {
var subjects []string var subjects []string
query := r.db.Model(&models.Assignment{}). query := r.db.Model(&models.Assignment{}).
@@ -364,3 +347,58 @@ func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, incl
return subjects, err return subjects, err
} }
func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority, filter string, page, pageSize int) ([]models.Assignment, int64, error) {
var assignments []models.Assignment
var totalCount int64
dbQuery := r.db.Model(&models.Assignment{}).Where("user_id = ?", userID)
if queryStr != "" {
dbQuery = dbQuery.Where("title LIKE ? OR description LIKE ?", "%"+queryStr+"%", "%"+queryStr+"%")
}
if priority != "" {
dbQuery = dbQuery.Where("priority = ?", priority)
}
now := time.Now()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
endOfDay := startOfDay.AddDate(0, 0, 1)
weekLater := startOfDay.AddDate(0, 0, 7)
switch filter {
case "completed":
dbQuery = dbQuery.Where("is_completed = ?", true)
case "overdue":
dbQuery = dbQuery.Where("is_completed = ? AND due_date < ?", false, now)
case "due_today":
dbQuery = dbQuery.Where("is_completed = ? AND due_date >= ? AND due_date < ?", false, startOfDay, endOfDay)
case "due_this_week":
dbQuery = dbQuery.Where("is_completed = ? AND due_date >= ? AND due_date < ?", false, startOfDay, weekLater)
case "recurring":
dbQuery = dbQuery.Where("recurring_assignment_id IS NOT NULL")
default:
dbQuery = dbQuery.Where("is_completed = ?", false)
}
if err := dbQuery.Count(&totalCount).Error; err != nil {
return nil, 0, err
}
if filter == "completed" {
dbQuery = dbQuery.Order("completed_at DESC")
} else {
dbQuery = dbQuery.Order("due_date ASC")
}
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 10
}
offset := (page - 1) * pageSize
err := dbQuery.Preload("RecurringAssignment").Limit(pageSize).Offset(offset).Find(&assignments).Error
return assignments, totalCount, err
}

View File

@@ -0,0 +1,126 @@
package repository
import (
"time"
"homework-manager/internal/database"
"homework-manager/internal/models"
"gorm.io/gorm"
)
type RecurringAssignmentRepository struct {
db *gorm.DB
}
func NewRecurringAssignmentRepository() *RecurringAssignmentRepository {
return &RecurringAssignmentRepository{db: database.GetDB()}
}
func (r *RecurringAssignmentRepository) Create(recurring *models.RecurringAssignment) error {
return r.db.Create(recurring).Error
}
func (r *RecurringAssignmentRepository) FindByID(id uint) (*models.RecurringAssignment, error) {
var recurring models.RecurringAssignment
err := r.db.First(&recurring, id).Error
if err != nil {
return nil, err
}
return &recurring, nil
}
func (r *RecurringAssignmentRepository) FindByUserID(userID uint) ([]models.RecurringAssignment, error) {
var recurrings []models.RecurringAssignment
err := r.db.Where("user_id = ?", userID).Order("created_at DESC").Find(&recurrings).Error
return recurrings, err
}
func (r *RecurringAssignmentRepository) FindActiveByUserID(userID uint) ([]models.RecurringAssignment, error) {
var recurrings []models.RecurringAssignment
err := r.db.Where("user_id = ? AND is_active = ?", userID, true).Order("created_at DESC").Find(&recurrings).Error
return recurrings, err
}
func (r *RecurringAssignmentRepository) Update(recurring *models.RecurringAssignment) error {
return r.db.Save(recurring).Error
}
func (r *RecurringAssignmentRepository) Delete(id uint) error {
return r.db.Delete(&models.RecurringAssignment{}, id).Error
}
func (r *RecurringAssignmentRepository) FindDueForGeneration() ([]models.RecurringAssignment, error) {
var recurrings []models.RecurringAssignment
err := r.db.Where("is_active = ? AND recurrence_type != ?", true, models.RecurrenceNone).
Find(&recurrings).Error
if err != nil {
return nil, err
}
var result []models.RecurringAssignment
now := time.Now()
for _, rec := range recurrings {
shouldGenerate := true
switch rec.EndType {
case models.EndTypeCount:
if rec.EndCount != nil && rec.GeneratedCount >= *rec.EndCount {
shouldGenerate = false
}
case models.EndTypeDate:
if rec.EndDate != nil && now.After(*rec.EndDate) {
shouldGenerate = false
}
}
if !shouldGenerate {
rec.IsActive = false
r.db.Save(&rec)
} else {
result = append(result, rec)
}
}
return result, nil
}
func (r *RecurringAssignmentRepository) GetLatestAssignmentByRecurringID(recurringID uint) (*models.Assignment, error) {
var assignment models.Assignment
err := r.db.Where("recurring_assignment_id = ?", recurringID).
Order("due_date DESC").
First(&assignment).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &assignment, nil
}
func (r *RecurringAssignmentRepository) GetAssignmentsByRecurringID(recurringID uint) ([]models.Assignment, error) {
var assignments []models.Assignment
err := r.db.Where("recurring_assignment_id = ?", recurringID).
Order("due_date ASC").
Find(&assignments).Error
return assignments, err
}
func (r *RecurringAssignmentRepository) GetFutureAssignmentsByRecurringID(recurringID uint, fromDate time.Time) ([]models.Assignment, error) {
var assignments []models.Assignment
err := r.db.Where("recurring_assignment_id = ? AND due_date >= ?", recurringID, fromDate).
Order("due_date ASC").
Find(&assignments).Error
return assignments, err
}
func (r *RecurringAssignmentRepository) CountPendingByRecurringID(recurringID uint) (int64, error) {
var count int64
err := r.db.Model(&models.Assignment{}).
Where("recurring_assignment_id = ? AND is_completed = ?", recurringID, false).
Count(&count).Error
return count, err
}

View File

@@ -14,6 +14,7 @@ import (
"homework-manager/internal/middleware" "homework-manager/internal/middleware"
"homework-manager/internal/service" "homework-manager/internal/service"
"github.com/dchest/captcha"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie" "github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -45,6 +46,22 @@ func getFuncMap() template.FuncMap {
"multiplyFloat": func(a float64, b float64) float64 { "multiplyFloat": func(a float64, b float64) float64 {
return a * b return a * b
}, },
"recurringLabel": service.GetRecurrenceTypeLabel,
"endTypeLabel": service.GetEndTypeLabel,
"recurringSummary": service.FormatRecurringSummary,
"derefInt": func(i *int) int {
if i == nil {
return 0
}
return *i
},
"seq": func(start, end int) []int {
var result []int
for i := start; i <= end; i++ {
result = append(result, i)
}
return result
},
} }
} }
@@ -59,9 +76,12 @@ func loadTemplates() (*template.Template, error) {
pattern string pattern string
prefix string prefix string
}{ }{
{"web/templates/auth/*.html", ""},
{"web/templates/pages/*.html", ""},
{"web/templates/auth/*.html", ""}, {"web/templates/auth/*.html", ""},
{"web/templates/pages/*.html", ""}, {"web/templates/pages/*.html", ""},
{"web/templates/assignments/*.html", "assignments/"}, {"web/templates/assignments/*.html", "assignments/"},
{"web/templates/recurring/*.html", "recurring/"},
{"web/templates/admin/*.html", "admin/"}, {"web/templates/admin/*.html", "admin/"},
} }
@@ -156,7 +176,8 @@ func Setup(cfg *config.Config) *gin.Engine {
r.Use(middleware.RequestTimer()) r.Use(middleware.RequestTimer())
securityConfig := middleware.SecurityConfig{ securityConfig := middleware.SecurityConfig{
HTTPS: cfg.HTTPS, HTTPS: cfg.HTTPS,
TurnstileEnabled: cfg.Captcha.Enabled && cfg.Captcha.Type == "turnstile",
} }
r.Use(middleware.SecurityHeaders(securityConfig)) r.Use(middleware.SecurityHeaders(securityConfig))
r.Use(middleware.ForceHTTPS(securityConfig)) r.Use(middleware.ForceHTTPS(securityConfig))
@@ -175,14 +196,23 @@ func Setup(cfg *config.Config) *gin.Engine {
apiKeyService := service.NewAPIKeyService() apiKeyService := service.NewAPIKeyService()
notificationService := service.NewNotificationService(cfg.Notification.TelegramBotToken) notificationService := service.NewNotificationService(cfg.Notification.TelegramBotToken)
// Start notification reminder scheduler
notificationService.StartReminderScheduler() notificationService.StartReminderScheduler()
authHandler := handler.NewAuthHandler() authHandler := handler.NewAuthHandler(cfg.Captcha)
assignmentHandler := handler.NewAssignmentHandler() assignmentHandler := handler.NewAssignmentHandler(notificationService)
adminHandler := handler.NewAdminHandler() adminHandler := handler.NewAdminHandler()
profileHandler := handler.NewProfileHandler(notificationService) profileHandler := handler.NewProfileHandler(notificationService)
apiHandler := handler.NewAPIHandler() apiHandler := handler.NewAPIHandler()
apiRecurringHandler := handler.NewAPIRecurringHandler()
r.GET("/captcha/:file", gin.WrapH(captcha.Server(captcha.StdWidth, captcha.StdHeight)))
r.GET("/captcha-new", func(c *gin.Context) {
id := captcha.New()
c.String(http.StatusOK, id)
})
r.GET("/login/2fa", csrfMiddleware, authHandler.ShowLogin2FA)
r.POST("/login/2fa", csrfMiddleware, authHandler.Login2FA)
guest := r.Group("/") guest := r.Group("/")
guest.Use(middleware.GuestOnly()) guest.Use(middleware.GuestOnly())
@@ -222,10 +252,20 @@ func Setup(cfg *config.Config) *gin.Engine {
auth.POST("/statistics/archive-subject", assignmentHandler.ArchiveSubject) auth.POST("/statistics/archive-subject", assignmentHandler.ArchiveSubject)
auth.POST("/statistics/unarchive-subject", assignmentHandler.UnarchiveSubject) auth.POST("/statistics/unarchive-subject", assignmentHandler.UnarchiveSubject)
auth.POST("/recurring/:id/stop", assignmentHandler.StopRecurring)
auth.POST("/recurring/:id/resume", assignmentHandler.ResumeRecurring)
auth.POST("/recurring/:id/delete", assignmentHandler.DeleteRecurring)
auth.GET("/recurring", assignmentHandler.ListRecurring)
auth.GET("/recurring/:id/edit", assignmentHandler.EditRecurring)
auth.POST("/recurring/:id", assignmentHandler.UpdateRecurring)
auth.GET("/profile", profileHandler.Show) auth.GET("/profile", profileHandler.Show)
auth.POST("/profile", profileHandler.Update) auth.POST("/profile", profileHandler.Update)
auth.POST("/profile/password", profileHandler.ChangePassword) auth.POST("/profile/password", profileHandler.ChangePassword)
auth.POST("/profile/notifications", profileHandler.UpdateNotificationSettings) auth.POST("/profile/notifications", profileHandler.UpdateNotificationSettings)
auth.GET("/profile/totp/setup", profileHandler.ShowTOTPSetup)
auth.POST("/profile/totp/setup", profileHandler.EnableTOTP)
auth.POST("/profile/totp/disable", profileHandler.DisableTOTP)
admin := auth.Group("/admin") admin := auth.Group("/admin")
admin.Use(middleware.AdminRequired()) admin.Use(middleware.AdminRequired())
@@ -254,7 +294,13 @@ func Setup(cfg *config.Config) *gin.Engine {
api.PUT("/assignments/:id", apiHandler.UpdateAssignment) api.PUT("/assignments/:id", apiHandler.UpdateAssignment)
api.DELETE("/assignments/:id", apiHandler.DeleteAssignment) api.DELETE("/assignments/:id", apiHandler.DeleteAssignment)
api.PATCH("/assignments/:id/toggle", apiHandler.ToggleAssignment) api.PATCH("/assignments/:id/toggle", apiHandler.ToggleAssignment)
api.GET("/statistics", apiHandler.GetStatistics) api.GET("/statistics", apiHandler.GetStatistics)
api.GET("/recurring", apiRecurringHandler.ListRecurring)
api.GET("/recurring/:id", apiRecurringHandler.GetRecurring)
api.PUT("/recurring/:id", apiRecurringHandler.UpdateRecurring)
api.DELETE("/recurring/:id", apiRecurringHandler.DeleteRecurring)
} }
return r return r

View File

@@ -179,7 +179,7 @@ func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filt
pageSize = 10 pageSize = 10
} }
assignments, totalCount, err := s.assignmentRepo.Search(userID, query, priority, filter, page, pageSize) assignments, totalCount, err := s.assignmentRepo.SearchWithPreload(userID, query, priority, filter, page, pageSize)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -209,7 +209,6 @@ func (s *AssignmentService) Update(userID, assignmentID uint, title, description
assignment.ReminderEnabled = reminderEnabled assignment.ReminderEnabled = reminderEnabled
assignment.ReminderAt = reminderAt assignment.ReminderAt = reminderAt
assignment.UrgentReminderEnabled = urgentReminderEnabled assignment.UrgentReminderEnabled = urgentReminderEnabled
// Reset reminder sent flag if reminder settings changed
if reminderEnabled && reminderAt != nil { if reminderEnabled && reminderAt != nil {
assignment.ReminderSent = false assignment.ReminderSent = false
} }
@@ -256,11 +255,11 @@ func (s *AssignmentService) GetSubjectsByUser(userID uint) ([]string, error) {
} }
type DashboardStats struct { type DashboardStats struct {
TotalPending int64 TotalPending int64
DueToday int DueToday int
DueThisWeek int DueThisWeek int
Overdue int Overdue int
Subjects []string Subjects []string
} }
func (s *AssignmentService) GetDashboardStats(userID uint) (*DashboardStats, error) { func (s *AssignmentService) GetDashboardStats(userID uint) (*DashboardStats, error) {
@@ -271,15 +270,14 @@ func (s *AssignmentService) GetDashboardStats(userID uint) (*DashboardStats, err
subjects, _ := s.assignmentRepo.GetSubjectsByUserID(userID) subjects, _ := s.assignmentRepo.GetSubjectsByUserID(userID)
return &DashboardStats{ return &DashboardStats{
TotalPending: pending, TotalPending: pending,
DueToday: len(dueToday), DueToday: len(dueToday),
DueThisWeek: len(dueThisWeek), DueThisWeek: len(dueThisWeek),
Overdue: int(overdueCount), Overdue: int(overdueCount),
Subjects: subjects, Subjects: subjects,
}, nil }, nil
} }
// StatisticsFilter holds filter parameters for statistics
type StatisticsFilter struct { type StatisticsFilter struct {
Subject string Subject string
From *time.Time From *time.Time
@@ -287,7 +285,6 @@ type StatisticsFilter struct {
IncludeArchived bool IncludeArchived bool
} }
// SubjectStats holds statistics for a subject
type SubjectStats struct { type SubjectStats struct {
Subject string `json:"subject"` Subject string `json:"subject"`
Total int64 `json:"total"` Total int64 `json:"total"`
@@ -298,7 +295,6 @@ type SubjectStats struct {
IsArchived bool `json:"is_archived,omitempty"` IsArchived bool `json:"is_archived,omitempty"`
} }
// StatisticsSummary holds overall statistics
type StatisticsSummary struct { type StatisticsSummary struct {
TotalAssignments int64 `json:"total_assignments"` TotalAssignments int64 `json:"total_assignments"`
CompletedAssignments int64 `json:"completed_assignments"` CompletedAssignments int64 `json:"completed_assignments"`
@@ -309,7 +305,6 @@ type StatisticsSummary struct {
Subjects []SubjectStats `json:"subjects,omitempty"` Subjects []SubjectStats `json:"subjects,omitempty"`
} }
// FilterInfo shows applied filters in response
type FilterInfo struct { type FilterInfo struct {
Subject *string `json:"subject"` Subject *string `json:"subject"`
From *string `json:"from"` From *string `json:"from"`
@@ -317,9 +312,7 @@ type FilterInfo struct {
IncludeArchived bool `json:"include_archived"` IncludeArchived bool `json:"include_archived"`
} }
// GetStatistics returns statistics for a user with optional filters
func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter) (*StatisticsSummary, error) { func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter) (*StatisticsSummary, error) {
// Convert filter to repository filter
repoFilter := repository.StatisticsFilter{ repoFilter := repository.StatisticsFilter{
Subject: filter.Subject, Subject: filter.Subject,
From: filter.From, From: filter.From,
@@ -327,7 +320,6 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
IncludeArchived: filter.IncludeArchived, IncludeArchived: filter.IncludeArchived,
} }
// Get overall statistics
stats, err := s.assignmentRepo.GetStatistics(userID, repoFilter) stats, err := s.assignmentRepo.GetStatistics(userID, repoFilter)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -341,7 +333,6 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
OnTimeCompletionRate: stats.OnTimeCompletionRate, OnTimeCompletionRate: stats.OnTimeCompletionRate,
} }
// Build filter info
filterInfo := &FilterInfo{} filterInfo := &FilterInfo{}
hasFilter := false hasFilter := false
if filter.Subject != "" { if filter.Subject != "" {
@@ -366,7 +357,6 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
summary.Filter = filterInfo summary.Filter = filterInfo
} }
// If no specific subject filter, get per-subject statistics
if filter.Subject == "" { if filter.Subject == "" {
subjectStats, err := s.assignmentRepo.GetStatisticsBySubjects(userID, repoFilter) subjectStats, err := s.assignmentRepo.GetStatisticsBySubjects(userID, repoFilter)
if err != nil { if err != nil {
@@ -388,23 +378,17 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
return summary, nil return summary, nil
} }
// ArchiveSubject archives all assignments for a subject
func (s *AssignmentService) ArchiveSubject(userID uint, subject string) error { func (s *AssignmentService) ArchiveSubject(userID uint, subject string) error {
return s.assignmentRepo.ArchiveBySubject(userID, subject) return s.assignmentRepo.ArchiveBySubject(userID, subject)
} }
// UnarchiveSubject unarchives all assignments for a subject
func (s *AssignmentService) UnarchiveSubject(userID uint, subject string) error { func (s *AssignmentService) UnarchiveSubject(userID uint, subject string) error {
return s.assignmentRepo.UnarchiveBySubject(userID, subject) return s.assignmentRepo.UnarchiveBySubject(userID, subject)
} }
// GetSubjectsWithArchived returns subjects optionally including archived
func (s *AssignmentService) GetSubjectsWithArchived(userID uint, includeArchived bool) ([]string, error) { func (s *AssignmentService) GetSubjectsWithArchived(userID uint, includeArchived bool) ([]string, error) {
return s.assignmentRepo.GetSubjectsByUserIDWithArchived(userID, includeArchived) return s.assignmentRepo.GetSubjectsByUserIDWithArchived(userID, includeArchived)
} }
// GetArchivedSubjects returns archived subjects only
func (s *AssignmentService) GetArchivedSubjects(userID uint) ([]string, error) { func (s *AssignmentService) GetArchivedSubjects(userID uint) ([]string, error) {
return s.assignmentRepo.GetArchivedSubjects(userID) return s.assignmentRepo.GetArchivedSubjects(userID)
} }

View File

@@ -104,3 +104,23 @@ func (s *AuthService) UpdateProfile(userID uint, name string) error {
user.Name = name user.Name = name
return s.userRepo.Update(user) return s.userRepo.Update(user)
} }
func (s *AuthService) EnableTOTP(userID uint, secret string) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return ErrUserNotFound
}
user.TOTPSecret = secret
user.TOTPEnabled = true
return s.userRepo.Update(user)
}
func (s *AuthService) DisableTOTP(userID uint) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return ErrUserNotFound
}
user.TOTPSecret = ""
user.TOTPEnabled = false
return s.userRepo.Update(user)
}

View File

@@ -0,0 +1,75 @@
package service
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/dchest/captcha"
)
type CaptchaService struct {
captchaType string
turnstileSecretKey string
}
func NewCaptchaService(captchaType, turnstileSecretKey string) *CaptchaService {
return &CaptchaService{
captchaType: captchaType,
turnstileSecretKey: turnstileSecretKey,
}
}
func (s *CaptchaService) NewImageCaptcha() string {
return captcha.New()
}
func (s *CaptchaService) VerifyImageCaptcha(id, answer string) bool {
return captcha.VerifyString(id, answer)
}
type turnstileResponse struct {
Success bool `json:"success"`
Errors []string `json:"error-codes"`
}
func (s *CaptchaService) VerifyTurnstile(token, remoteIP string) (bool, error) {
if token == "" {
return false, nil
}
form := url.Values{}
form.Set("secret", s.turnstileSecretKey)
form.Set("response", token)
if remoteIP != "" {
form.Set("remoteip", remoteIP)
}
resp, err := http.Post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
"application/x-www-form-urlencoded",
strings.NewReader(form.Encode()),
)
if err != nil {
return false, fmt.Errorf("turnstile request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return false, fmt.Errorf("reading turnstile response: %w", err)
}
var result turnstileResponse
if err := json.Unmarshal(body, &result); err != nil {
return false, fmt.Errorf("parsing turnstile response: %w", err)
}
return result.Success, nil
}
func (s *CaptchaService) Type() string {
return s.captchaType
}

View File

@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@@ -14,24 +13,20 @@ import (
"homework-manager/internal/models" "homework-manager/internal/models"
) )
// NotificationService handles Telegram and LINE notifications
type NotificationService struct { type NotificationService struct {
telegramBotToken string telegramBotToken string
} }
// NewNotificationService creates a new notification service
func NewNotificationService(telegramBotToken string) *NotificationService { func NewNotificationService(telegramBotToken string) *NotificationService {
return &NotificationService{ return &NotificationService{
telegramBotToken: telegramBotToken, telegramBotToken: telegramBotToken,
} }
} }
// GetUserSettings retrieves notification settings for a user
func (s *NotificationService) GetUserSettings(userID uint) (*models.UserNotificationSettings, error) { func (s *NotificationService) GetUserSettings(userID uint) (*models.UserNotificationSettings, error) {
var settings models.UserNotificationSettings var settings models.UserNotificationSettings
result := database.GetDB().Where("user_id = ?", userID).First(&settings) result := database.GetDB().Where("user_id = ?", userID).First(&settings)
if result.Error != nil { if result.Error != nil {
// If not found, return a new empty settings object
if result.RowsAffected == 0 { if result.RowsAffected == 0 {
return &models.UserNotificationSettings{ return &models.UserNotificationSettings{
UserID: userID, UserID: userID,
@@ -42,24 +37,19 @@ func (s *NotificationService) GetUserSettings(userID uint) (*models.UserNotifica
return &settings, nil return &settings, nil
} }
// UpdateUserSettings updates notification settings for a user
func (s *NotificationService) UpdateUserSettings(userID uint, settings *models.UserNotificationSettings) error { func (s *NotificationService) UpdateUserSettings(userID uint, settings *models.UserNotificationSettings) error {
settings.UserID = userID settings.UserID = userID
var existing models.UserNotificationSettings var existing models.UserNotificationSettings
result := database.GetDB().Where("user_id = ?", userID).First(&existing) result := database.GetDB().Where("user_id = ?", userID).First(&existing)
if result.RowsAffected == 0 { if result.RowsAffected == 0 {
// Create new
return database.GetDB().Create(settings).Error return database.GetDB().Create(settings).Error
} }
// Update existing
settings.ID = existing.ID settings.ID = existing.ID
return database.GetDB().Save(settings).Error return database.GetDB().Save(settings).Error
} }
// SendTelegramNotification sends a message via Telegram Bot API
func (s *NotificationService) SendTelegramNotification(chatID, message string) error { func (s *NotificationService) SendTelegramNotification(chatID, message string) error {
if s.telegramBotToken == "" { if s.telegramBotToken == "" {
return fmt.Errorf("telegram bot token is not configured") return fmt.Errorf("telegram bot token is not configured")
@@ -69,65 +59,31 @@ func (s *NotificationService) SendTelegramNotification(chatID, message string) e
} }
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", s.telegramBotToken) apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", s.telegramBotToken)
payload := map[string]string{ payload := map[string]string{
"chat_id": chatID, "chat_id": chatID,
"text": message, "text": message,
"parse_mode": "HTML", "parse_mode": "HTML",
} }
jsonPayload, err := json.Marshal(payload) jsonPayload, err := json.Marshal(payload)
if err != nil { if err != nil {
return err return err
} }
resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonPayload)) resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonPayload))
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("telegram API returned status %d", resp.StatusCode) return fmt.Errorf("telegram API returned status %d", resp.StatusCode)
} }
return nil return nil
} }
// SendLineNotification sends a message via LINE Notify API
func (s *NotificationService) SendLineNotification(token, message string) error {
if token == "" {
return fmt.Errorf("LINE Notify token is empty")
}
apiURL := "https://notify-api.line.me/api/notify"
data := url.Values{}
data.Set("message", message)
req, err := http.NewRequest("POST", apiURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("LINE Notify API returned status %d", resp.StatusCode)
}
return nil
}
// SendAssignmentReminder sends a reminder notification for an assignment
func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *models.Assignment) error { func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *models.Assignment) error {
settings, err := s.GetUserSettings(userID) settings, err := s.GetUserSettings(userID)
if err != nil { if err != nil {
@@ -144,20 +100,12 @@ func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *mo
var errors []string var errors []string
// Send to Telegram if enabled
if settings.TelegramEnabled && settings.TelegramChatID != "" { if settings.TelegramEnabled && settings.TelegramChatID != "" {
if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil { if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil {
errors = append(errors, fmt.Sprintf("Telegram: %v", err)) errors = append(errors, fmt.Sprintf("Telegram: %v", err))
} }
} }
// Send to LINE if enabled
if settings.LineEnabled && settings.LineNotifyToken != "" {
if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil {
errors = append(errors, fmt.Sprintf("LINE: %v", err))
}
}
if len(errors) > 0 { if len(errors) > 0 {
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; ")) return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
} }
@@ -165,7 +113,57 @@ func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *mo
return nil return nil
} }
// SendUrgentReminder sends an urgent reminder notification for an assignment func (s *NotificationService) SendAssignmentCreatedNotification(userID uint, assignment *models.Assignment) error {
settings, err := s.GetUserSettings(userID)
if err != nil {
return err
}
if !settings.NotifyOnCreate {
return nil
}
if !settings.TelegramEnabled {
return nil
}
message := fmt.Sprintf(
"新しい課題が追加されました\n\n【%s】\n科目: %s\n優先度: %s\n期限: %s\n\n%s",
assignment.Title,
assignment.Subject,
getPriorityLabel(assignment.Priority),
assignment.DueDate.Format("2006/01/02 15:04"),
assignment.Description,
)
var errors []string
if settings.TelegramEnabled && settings.TelegramChatID != "" {
if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil {
errors = append(errors, fmt.Sprintf("Telegram: %v", err))
}
}
if len(errors) > 0 {
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
}
return nil
}
func getPriorityLabel(priority string) string {
switch priority {
case "high":
return "大"
case "medium":
return "中"
case "low":
return "小"
default:
return priority
}
}
func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models.Assignment) error { func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models.Assignment) error {
settings, err := s.GetUserSettings(userID) settings, err := s.GetUserSettings(userID)
if err != nil { if err != nil {
@@ -203,20 +201,12 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models
var errors []string var errors []string
// Send to Telegram if enabled
if settings.TelegramEnabled && settings.TelegramChatID != "" { if settings.TelegramEnabled && settings.TelegramChatID != "" {
if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil { if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil {
errors = append(errors, fmt.Sprintf("Telegram: %v", err)) errors = append(errors, fmt.Sprintf("Telegram: %v", err))
} }
} }
// Send to LINE if enabled
if settings.LineEnabled && settings.LineNotifyToken != "" {
if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil {
errors = append(errors, fmt.Sprintf("LINE: %v", err))
}
}
if len(errors) > 0 { if len(errors) > 0 {
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; ")) return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
} }
@@ -224,8 +214,6 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models
return nil return nil
} }
// getUrgentReminderInterval returns the reminder interval based on priority
// high=10min, medium=30min, low=60min
func getUrgentReminderInterval(priority string) time.Duration { func getUrgentReminderInterval(priority string) time.Duration {
switch priority { switch priority {
case "high": case "high":
@@ -239,87 +227,78 @@ func getUrgentReminderInterval(priority string) time.Duration {
} }
} }
// ProcessPendingReminders checks and sends pending one-time reminders
func (s *NotificationService) ProcessPendingReminders() { func (s *NotificationService) ProcessPendingReminders() {
now := time.Now() now := time.Now()
var assignments []models.Assignment var assignments []models.Assignment
result := database.GetDB().Where( result := database.GetDB().Where(
"reminder_enabled = ? AND reminder_sent = ? AND reminder_at <= ? AND is_completed = ?", "reminder_enabled = ? AND reminder_sent = ? AND reminder_at <= ? AND is_completed = ?",
true, false, now, false, true, false, now, false,
).Find(&assignments) ).Find(&assignments)
if result.Error != nil { if result.Error != nil {
log.Printf("Error fetching pending reminders: %v", result.Error) log.Printf("Error fetching pending reminders: %v", result.Error)
return return
} }
for _, assignment := range assignments { for _, assignment := range assignments {
if err := s.SendAssignmentReminder(assignment.UserID, &assignment); err != nil { if err := s.SendAssignmentReminder(assignment.UserID, &assignment); err != nil {
log.Printf("Error sending reminder for assignment %d: %v", assignment.ID, err) log.Printf("Error sending reminder for assignment %d: %v", assignment.ID, err)
continue continue
} }
// Mark as sent
database.GetDB().Model(&assignment).Update("reminder_sent", true) database.GetDB().Model(&assignment).Update("reminder_sent", true)
log.Printf("Sent reminder for assignment %d to user %d", assignment.ID, assignment.UserID) log.Printf("Sent reminder for assignment %d to user %d", assignment.ID, assignment.UserID)
} }
} }
// ProcessUrgentReminders checks and sends urgent (repeating) reminders
// Starts 3 hours before deadline, repeats at interval based on priority
func (s *NotificationService) ProcessUrgentReminders() { func (s *NotificationService) ProcessUrgentReminders() {
now := time.Now() now := time.Now()
urgentStartTime := 3 * time.Hour // Start 3 hours before deadline urgentStartTime := 3 * time.Hour
var assignments []models.Assignment var assignments []models.Assignment
result := database.GetDB().Where( result := database.GetDB().Where(
"urgent_reminder_enabled = ? AND is_completed = ? AND due_date > ?", "urgent_reminder_enabled = ? AND is_completed = ? AND due_date > ?",
true, false, now, true, false, now,
).Find(&assignments) ).Find(&assignments)
if result.Error != nil { if result.Error != nil {
log.Printf("Error fetching urgent reminders: %v", result.Error) log.Printf("Error fetching urgent reminders: %v", result.Error)
return return
} }
for _, assignment := range assignments { for _, assignment := range assignments {
timeUntilDue := assignment.DueDate.Sub(now) timeUntilDue := assignment.DueDate.Sub(now)
// Only send if within 3 hours of deadline
if timeUntilDue > urgentStartTime { if timeUntilDue > urgentStartTime {
continue continue
} }
// Check if enough time has passed since last urgent reminder
interval := getUrgentReminderInterval(assignment.Priority) interval := getUrgentReminderInterval(assignment.Priority)
if assignment.LastUrgentReminderSent != nil { if assignment.LastUrgentReminderSent != nil {
timeSinceLastReminder := now.Sub(*assignment.LastUrgentReminderSent) timeSinceLastReminder := now.Sub(*assignment.LastUrgentReminderSent)
if timeSinceLastReminder < interval { if timeSinceLastReminder < interval {
continue continue
} }
} }
// Send urgent reminder
if err := s.SendUrgentReminder(assignment.UserID, &assignment); err != nil { if err := s.SendUrgentReminder(assignment.UserID, &assignment); err != nil {
log.Printf("Error sending urgent reminder for assignment %d: %v", assignment.ID, err) log.Printf("Error sending urgent reminder for assignment %d: %v", assignment.ID, err)
continue continue
} }
// Update last sent time
database.GetDB().Model(&assignment).Update("last_urgent_reminder_sent", now) database.GetDB().Model(&assignment).Update("last_urgent_reminder_sent", now)
log.Printf("Sent urgent reminder for assignment %d (priority: %s) to user %d", log.Printf("Sent urgent reminder for assignment %d (priority: %s) to user %d",
assignment.ID, assignment.Priority, assignment.UserID) assignment.ID, assignment.Priority, assignment.UserID)
} }
} }
// StartReminderScheduler starts a background goroutine to process reminders
func (s *NotificationService) StartReminderScheduler() { func (s *NotificationService) StartReminderScheduler() {
go func() { go func() {
ticker := time.NewTicker(1 * time.Minute) ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for range ticker.C {
s.ProcessPendingReminders() s.ProcessPendingReminders()
s.ProcessUrgentReminders() s.ProcessUrgentReminders()
@@ -327,4 +306,3 @@ func (s *NotificationService) StartReminderScheduler() {
}() }()
log.Println("Reminder scheduler started (one-time + urgent reminders)") log.Println("Reminder scheduler started (one-time + urgent reminders)")
} }

View File

@@ -0,0 +1,521 @@
package service
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"homework-manager/internal/models"
"homework-manager/internal/repository"
)
var (
ErrRecurringAssignmentNotFound = errors.New("recurring assignment not found")
ErrRecurringUnauthorized = errors.New("unauthorized")
ErrInvalidRecurrenceType = errors.New("invalid recurrence type")
ErrInvalidEndType = errors.New("invalid end type")
)
type RecurringAssignmentService struct {
recurringRepo *repository.RecurringAssignmentRepository
assignmentRepo *repository.AssignmentRepository
}
func NewRecurringAssignmentService() *RecurringAssignmentService {
return &RecurringAssignmentService{
recurringRepo: repository.NewRecurringAssignmentRepository(),
assignmentRepo: repository.NewAssignmentRepository(),
}
}
type CreateRecurringAssignmentInput struct {
Title string
Description string
Subject string
Priority string
RecurrenceType string
RecurrenceInterval int
RecurrenceWeekday *int
RecurrenceDay *int
DueTime string
EndType string
EndCount *int
EndDate *time.Time
EditBehavior string
ReminderEnabled bool
ReminderOffset *int
UrgentReminderEnabled bool
FirstDueDate time.Time
}
func (s *RecurringAssignmentService) Create(userID uint, input CreateRecurringAssignmentInput) (*models.RecurringAssignment, error) {
if !isValidRecurrenceType(input.RecurrenceType) {
return nil, ErrInvalidRecurrenceType
}
if !isValidEndType(input.EndType) {
return nil, ErrInvalidEndType
}
if input.RecurrenceInterval < 1 {
input.RecurrenceInterval = 1
}
if input.EditBehavior == "" {
input.EditBehavior = models.EditBehaviorThisOnly
}
recurring := &models.RecurringAssignment{
UserID: userID,
Title: input.Title,
Description: input.Description,
Subject: input.Subject,
Priority: input.Priority,
RecurrenceType: input.RecurrenceType,
RecurrenceInterval: input.RecurrenceInterval,
RecurrenceWeekday: input.RecurrenceWeekday,
RecurrenceDay: input.RecurrenceDay,
DueTime: input.DueTime,
EndType: input.EndType,
EndCount: input.EndCount,
EndDate: input.EndDate,
EditBehavior: input.EditBehavior,
ReminderEnabled: input.ReminderEnabled,
ReminderOffset: input.ReminderOffset,
UrgentReminderEnabled: input.UrgentReminderEnabled,
IsActive: true,
GeneratedCount: 0,
}
if err := s.recurringRepo.Create(recurring); err != nil {
return nil, err
}
if err := s.generateAssignment(recurring, input.FirstDueDate); err != nil {
return nil, err
}
return recurring, nil
}
func (s *RecurringAssignmentService) GetByID(userID, recurringID uint) (*models.RecurringAssignment, error) {
recurring, err := s.recurringRepo.FindByID(recurringID)
if err != nil {
return nil, ErrRecurringAssignmentNotFound
}
if recurring.UserID != userID {
return nil, ErrRecurringUnauthorized
}
return recurring, nil
}
func (s *RecurringAssignmentService) GetAllByUser(userID uint) ([]models.RecurringAssignment, error) {
return s.recurringRepo.FindByUserID(userID)
}
func (s *RecurringAssignmentService) GetActiveByUser(userID uint) ([]models.RecurringAssignment, error) {
return s.recurringRepo.FindActiveByUserID(userID)
}
type UpdateRecurringInput struct {
Title *string
Description *string
Subject *string
Priority *string
RecurrenceType *string
RecurrenceInterval *int
RecurrenceWeekday *int
RecurrenceDay *int
DueTime *string
EndType *string
EndCount *int
EndDate *time.Time
EditBehavior string
ReminderEnabled *bool
ReminderOffset *int
UrgentReminderEnabled *bool
}
func (s *RecurringAssignmentService) Update(userID, recurringID uint, input UpdateRecurringInput) (*models.RecurringAssignment, error) {
recurring, err := s.GetByID(userID, recurringID)
if err != nil {
return nil, err
}
if input.Title != nil {
recurring.Title = *input.Title
}
if input.Description != nil {
recurring.Description = *input.Description
}
if input.Subject != nil {
recurring.Subject = *input.Subject
}
if input.Priority != nil {
recurring.Priority = *input.Priority
}
if input.DueTime != nil {
recurring.DueTime = *input.DueTime
}
if input.EditBehavior != "" {
recurring.EditBehavior = input.EditBehavior
}
if input.ReminderEnabled != nil {
recurring.ReminderEnabled = *input.ReminderEnabled
}
if input.ReminderOffset != nil {
recurring.ReminderOffset = input.ReminderOffset
}
if input.UrgentReminderEnabled != nil {
recurring.UrgentReminderEnabled = *input.UrgentReminderEnabled
}
if input.RecurrenceType != nil && *input.RecurrenceType != "" && isValidRecurrenceType(*input.RecurrenceType) {
recurring.RecurrenceType = *input.RecurrenceType
}
if input.RecurrenceInterval != nil && *input.RecurrenceInterval > 0 {
recurring.RecurrenceInterval = *input.RecurrenceInterval
}
if input.RecurrenceWeekday != nil {
recurring.RecurrenceWeekday = input.RecurrenceWeekday
}
if input.RecurrenceDay != nil {
recurring.RecurrenceDay = input.RecurrenceDay
}
if input.EndType != nil && isValidEndType(*input.EndType) {
recurring.EndType = *input.EndType
}
if input.EndCount != nil {
recurring.EndCount = input.EndCount
}
if input.EndDate != nil {
recurring.EndDate = input.EndDate
}
if err := s.recurringRepo.Update(recurring); err != nil {
return nil, err
}
return recurring, nil
}
func (s *RecurringAssignmentService) SetActive(userID, recurringID uint, isActive bool) error {
recurring, err := s.GetByID(userID, recurringID)
if err != nil {
return err
}
recurring.IsActive = isActive
return s.recurringRepo.Update(recurring)
}
func (s *RecurringAssignmentService) UpdateAssignmentWithBehavior(
userID uint,
assignment *models.Assignment,
title, description, subject, priority string,
dueDate time.Time,
reminderEnabled bool,
reminderAt *time.Time,
urgentReminderEnabled bool,
editBehavior string,
) error {
if assignment.RecurringAssignmentID == nil {
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
}
recurring, err := s.GetByID(userID, *assignment.RecurringAssignmentID)
if err != nil {
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
}
switch editBehavior {
case models.EditBehaviorThisOnly:
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
case models.EditBehaviorThisAndFuture:
if err := s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled); err != nil {
return err
}
recurring.Title = title
recurring.Description = description
recurring.Subject = subject
recurring.Priority = priority
recurring.UrgentReminderEnabled = urgentReminderEnabled
if err := s.recurringRepo.Update(recurring); err != nil {
return err
}
return s.updateFutureAssignments(recurring.ID, assignment.DueDate, title, description, subject, priority, urgentReminderEnabled)
case models.EditBehaviorAll:
recurring.Title = title
recurring.Description = description
recurring.Subject = subject
recurring.Priority = priority
recurring.UrgentReminderEnabled = urgentReminderEnabled
if err := s.recurringRepo.Update(recurring); err != nil {
return err
}
return s.updateAllPendingAssignments(recurring.ID, title, description, subject, priority, urgentReminderEnabled)
default:
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
}
}
func (s *RecurringAssignmentService) updateSingleAssignment(
assignment *models.Assignment,
title, description, subject, priority string,
dueDate time.Time,
reminderEnabled bool,
reminderAt *time.Time,
urgentReminderEnabled bool,
) error {
assignment.Title = title
assignment.Description = description
assignment.Subject = subject
assignment.Priority = priority
assignment.DueDate = dueDate
assignment.ReminderEnabled = reminderEnabled
assignment.ReminderAt = reminderAt
assignment.UrgentReminderEnabled = urgentReminderEnabled
return s.assignmentRepo.Update(assignment)
}
func (s *RecurringAssignmentService) updateFutureAssignments(
recurringID uint,
fromDate time.Time,
title, description, subject, priority string,
urgentReminderEnabled bool,
) error {
assignments, err := s.recurringRepo.GetFutureAssignmentsByRecurringID(recurringID, fromDate)
if err != nil {
return err
}
for _, a := range assignments {
if a.IsCompleted {
continue
}
a.Title = title
a.Description = description
a.Subject = subject
a.Priority = priority
a.UrgentReminderEnabled = urgentReminderEnabled
if err := s.assignmentRepo.Update(&a); err != nil {
return err
}
}
return nil
}
func (s *RecurringAssignmentService) updateAllPendingAssignments(
recurringID uint,
title, description, subject, priority string,
urgentReminderEnabled bool,
) error {
assignments, err := s.recurringRepo.GetAssignmentsByRecurringID(recurringID)
if err != nil {
return err
}
for _, a := range assignments {
if a.IsCompleted {
continue
}
a.Title = title
a.Description = description
a.Subject = subject
a.Priority = priority
a.UrgentReminderEnabled = urgentReminderEnabled
if err := s.assignmentRepo.Update(&a); err != nil {
return err
}
}
return nil
}
func (s *RecurringAssignmentService) Delete(userID, recurringID uint, deleteFutureAssignments bool) error {
recurring, err := s.GetByID(userID, recurringID)
if err != nil {
return err
}
if deleteFutureAssignments {
assignments, err := s.recurringRepo.GetFutureAssignmentsByRecurringID(recurringID, time.Now())
if err != nil {
return err
}
for _, a := range assignments {
if !a.IsCompleted {
s.assignmentRepo.Delete(a.ID)
}
}
}
return s.recurringRepo.Delete(recurring.ID)
}
func (s *RecurringAssignmentService) GenerateNextAssignments() error {
recurrings, err := s.recurringRepo.FindDueForGeneration()
if err != nil {
return err
}
for _, recurring := range recurrings {
pendingCount, err := s.recurringRepo.CountPendingByRecurringID(recurring.ID)
if err != nil {
continue
}
if pendingCount == 0 {
latest, err := s.recurringRepo.GetLatestAssignmentByRecurringID(recurring.ID)
if err != nil {
continue
}
var nextDueDate time.Time
if latest != nil {
nextDueDate = recurring.CalculateNextDueDate(latest.DueDate)
} else {
nextDueDate = time.Now()
}
if nextDueDate.After(time.Now()) {
s.generateAssignment(&recurring, nextDueDate)
}
}
}
return nil
}
func (s *RecurringAssignmentService) generateAssignment(recurring *models.RecurringAssignment, dueDate time.Time) error {
if recurring.DueTime != "" {
parts := strings.Split(recurring.DueTime, ":")
if len(parts) == 2 {
hour, _ := strconv.Atoi(parts[0])
minute, _ := strconv.Atoi(parts[1])
dueDate = time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), hour, minute, 0, 0, dueDate.Location())
}
}
var reminderAt *time.Time
if recurring.ReminderEnabled && recurring.ReminderOffset != nil {
t := dueDate.Add(-time.Duration(*recurring.ReminderOffset) * time.Minute)
reminderAt = &t
}
assignment := &models.Assignment{
UserID: userID(recurring.UserID),
Title: recurring.Title,
Description: recurring.Description,
Subject: recurring.Subject,
Priority: recurring.Priority,
DueDate: dueDate,
ReminderEnabled: recurring.ReminderEnabled,
ReminderAt: reminderAt,
UrgentReminderEnabled: recurring.UrgentReminderEnabled,
RecurringAssignmentID: &recurring.ID,
}
if err := s.assignmentRepo.Create(assignment); err != nil {
return err
}
recurring.GeneratedCount++
return s.recurringRepo.Update(recurring)
}
func userID(id uint) uint {
return id
}
func isValidRecurrenceType(t string) bool {
switch t {
case models.RecurrenceNone, models.RecurrenceDaily, models.RecurrenceWeekly, models.RecurrenceMonthly:
return true
}
return false
}
func isValidEndType(t string) bool {
switch t {
case models.EndTypeNever, models.EndTypeCount, models.EndTypeDate:
return true
}
return false
}
func GetRecurrenceTypeLabel(t string) string {
switch t {
case models.RecurrenceDaily:
return "毎日"
case models.RecurrenceWeekly:
return "毎週"
case models.RecurrenceMonthly:
return "毎月"
default:
return "なし"
}
}
func GetEndTypeLabel(t string) string {
switch t {
case models.EndTypeCount:
return "回数指定"
case models.EndTypeDate:
return "終了日指定"
default:
return "無期限"
}
}
func FormatRecurringSummary(recurring *models.RecurringAssignment) string {
if recurring.RecurrenceType == models.RecurrenceNone {
return ""
}
var parts []string
typeLabel := GetRecurrenceTypeLabel(recurring.RecurrenceType)
if recurring.RecurrenceInterval > 1 {
switch recurring.RecurrenceType {
case models.RecurrenceDaily:
parts = append(parts, fmt.Sprintf("%d日ごと", recurring.RecurrenceInterval))
case models.RecurrenceWeekly:
parts = append(parts, fmt.Sprintf("%d週間ごと", recurring.RecurrenceInterval))
case models.RecurrenceMonthly:
parts = append(parts, fmt.Sprintf("%dヶ月ごと", recurring.RecurrenceInterval))
}
} else {
parts = append(parts, typeLabel)
}
if recurring.RecurrenceType == models.RecurrenceWeekly && recurring.RecurrenceWeekday != nil {
weekdays := []string{"日", "月", "火", "水", "木", "金", "土"}
if *recurring.RecurrenceWeekday >= 0 && *recurring.RecurrenceWeekday < 7 {
parts = append(parts, fmt.Sprintf("(%s曜日)", weekdays[*recurring.RecurrenceWeekday]))
}
}
if recurring.RecurrenceType == models.RecurrenceMonthly && recurring.RecurrenceDay != nil {
parts = append(parts, fmt.Sprintf("(%d日)", *recurring.RecurrenceDay))
}
switch recurring.EndType {
case models.EndTypeCount:
if recurring.EndCount != nil {
parts = append(parts, fmt.Sprintf("/ %d回まで", *recurring.EndCount))
}
case models.EndTypeDate:
if recurring.EndDate != nil {
parts = append(parts, fmt.Sprintf("/ %sまで", recurring.EndDate.Format("2006/01/02")))
}
}
return strings.Join(parts, " ")
}

View File

@@ -0,0 +1,71 @@
package service
import (
"bytes"
"encoding/base64"
"fmt"
"image/png"
"net/url"
"github.com/pquerna/otp"
totplib "github.com/pquerna/otp/totp"
)
type TOTPService struct{}
func NewTOTPService() *TOTPService {
return &TOTPService{}
}
type TOTPSetupData struct {
Secret string
QRCodeB64 string
OTPAuthURL string
}
func (s *TOTPService) GenerateSecret(email, issuer string) (*TOTPSetupData, error) {
key, err := totplib.Generate(totplib.GenerateOpts{
Issuer: issuer,
AccountName: email,
})
if err != nil {
return nil, err
}
return s.buildSetupData(key)
}
func (s *TOTPService) SetupDataFromSecret(secret, email, issuer string) (*TOTPSetupData, error) {
otpURL := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s",
url.PathEscape(issuer),
url.PathEscape(email),
url.QueryEscape(secret),
url.QueryEscape(issuer),
)
key, err := otp.NewKeyFromURL(otpURL)
if err != nil {
return nil, err
}
return s.buildSetupData(key)
}
func (s *TOTPService) buildSetupData(key *otp.Key) (*TOTPSetupData, error) {
img, err := key.Image(200, 200)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return &TOTPSetupData{
Secret: key.Secret(),
QRCodeB64: base64.StdEncoding.EncodeToString(buf.Bytes()),
OTPAuthURL: key.URL(),
}, nil
}
func (s *TOTPService) Validate(secret, code string) bool {
return totplib.Validate(code, secret)
}

View File

@@ -0,0 +1,173 @@
package validation
import (
"fmt"
"regexp"
"strings"
"unicode"
)
var MaxLengths = map[string]int{
"title": 200,
"description": 5000,
"subject": 100,
"priority": 20,
}
var xssPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)<\s*script`),
regexp.MustCompile(`(?i)</\s*script`),
regexp.MustCompile(`(?i)javascript\s*:`),
regexp.MustCompile(`(?i)on\w+\s*=`),
regexp.MustCompile(`(?i)<\s*iframe`),
regexp.MustCompile(`(?i)<\s*object`),
regexp.MustCompile(`(?i)<\s*embed`),
regexp.MustCompile(`(?i)<\s*svg[^>]*on\w+\s*=`),
regexp.MustCompile(`(?i)data\s*:\s*text/html`),
regexp.MustCompile(`(?i)<\s*img[^>]*on\w+\s*=`),
regexp.MustCompile(`(?i)expression\s*\(`),
regexp.MustCompile(`(?i)alert\s*\(`),
regexp.MustCompile(`(?i)confirm\s*\(`),
regexp.MustCompile(`(?i)prompt\s*\(`),
regexp.MustCompile(`(?i)document\s*\.\s*cookie`),
regexp.MustCompile(`(?i)document\s*\.\s*location`),
regexp.MustCompile(`(?i)window\s*\.\s*location`),
}
var sqlInjectionPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)'\s*or\s+`),
regexp.MustCompile(`(?i)'\s*and\s+`),
regexp.MustCompile(`(?i)"\s*or\s+`),
regexp.MustCompile(`(?i)"\s*and\s+`),
regexp.MustCompile(`(?i)union\s+(all\s+)?select`),
regexp.MustCompile(`(?i);\s*(drop|delete|update|insert|alter|truncate)\s+`),
regexp.MustCompile(`(?i)--\s*$`),
regexp.MustCompile(`(?i)/\*.*\*/`),
regexp.MustCompile(`(?i)'\s*;\s*`),
regexp.MustCompile(`(?i)exec\s*\(`),
regexp.MustCompile(`(?i)xp_\w+`),
regexp.MustCompile(`(?i)load_file\s*\(`),
regexp.MustCompile(`(?i)into\s+(out|dump)file`),
regexp.MustCompile(`(?i)benchmark\s*\(`),
regexp.MustCompile(`(?i)sleep\s*\(\s*\d`),
regexp.MustCompile(`(?i)waitfor\s+delay`),
regexp.MustCompile(`(?i)1\s*=\s*1`),
regexp.MustCompile(`(?i)'1'\s*=\s*'1`),
}
var pathTraversalPatterns = []*regexp.Regexp{
regexp.MustCompile(`\.\.[\\/]`),
regexp.MustCompile(`\.\.%2[fF]`),
regexp.MustCompile(`%2e%2e[\\/]`),
regexp.MustCompile(`\.\./`),
regexp.MustCompile(`\.\.\\`),
}
var commandInjectionPatterns = []*regexp.Regexp{
regexp.MustCompile(`^\s*;`),
regexp.MustCompile(`;\s*\w+`),
regexp.MustCompile(`\|\s*\w+`),
regexp.MustCompile("`[^`]+`"),
regexp.MustCompile(`\$\([^)]+\)`),
regexp.MustCompile(`&&\s*\w+`),
regexp.MustCompile(`\|\|\s*\w+`),
}
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
func ValidateAssignmentInput(title, description, subject, priority string) error {
if err := ValidateField("title", title, true); err != nil {
return err
}
if err := ValidateField("description", description, false); err != nil {
return err
}
if err := ValidateField("subject", subject, false); err != nil {
return err
}
if err := ValidateField("priority", priority, false); err != nil {
return err
}
return nil
}
func ValidateField(fieldName, value string, required bool) error {
if required && strings.TrimSpace(value) == "" {
return &ValidationError{Field: fieldName, Message: "必須項目です"}
}
if value == "" {
return nil
}
if maxLen, ok := MaxLengths[fieldName]; ok {
if len(value) > maxLen {
return &ValidationError{
Field: fieldName,
Message: fmt.Sprintf("最大%d文字までです", maxLen),
}
}
}
if fieldName != "description" {
for _, r := range value {
if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' {
return &ValidationError{
Field: fieldName,
Message: "不正な制御文字が含まれています",
}
}
}
}
for _, pattern := range xssPatterns {
if pattern.MatchString(value) {
return &ValidationError{
Field: fieldName,
Message: "潜在的に危険なHTMLタグまたはスクリプトが含まれています",
}
}
}
for _, pattern := range sqlInjectionPatterns {
if pattern.MatchString(value) {
return &ValidationError{
Field: fieldName,
Message: "潜在的に危険なSQL構文が含まれています",
}
}
}
for _, pattern := range pathTraversalPatterns {
if pattern.MatchString(value) {
return &ValidationError{
Field: fieldName,
Message: "不正なパス文字列が含まれています",
}
}
}
for _, pattern := range commandInjectionPatterns {
if pattern.MatchString(value) {
return &ValidationError{
Field: fieldName,
Message: "潜在的に危険なコマンド構文が含まれています",
}
}
}
return nil
}
func SanitizeString(s string) string {
s = strings.ReplaceAll(s, "\x00", "")
s = strings.TrimSpace(s)
return s
}

View File

@@ -1,28 +1,56 @@
// Homework Manager JavaScript const XSS = {
escapeHtml: function (str) {
if (str === null || str === undefined) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
document.addEventListener('DOMContentLoaded', function() { setTextSafe: function (element, text) {
// Auto-dismiss alerts after 5 seconds if (element) {
const alerts = document.querySelectorAll('.alert:not(.alert-danger)'); element.textContent = text;
alerts.forEach(function(alert) { }
setTimeout(function() { },
sanitizeUrl: function (url) {
if (!url) return '';
const cleaned = String(url).replace(/[\x00-\x1F\x7F]/g, '').trim();
try {
const parsed = new URL(cleaned, window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.href;
}
} catch (e) {
if (cleaned.startsWith('/') && !cleaned.startsWith('//')) {
return cleaned;
}
}
return '';
}
};
window.XSS = XSS;
document.addEventListener('DOMContentLoaded', function () {
const alerts = document.querySelectorAll('.alert:not(.alert-danger):not(.modal .alert)');
alerts.forEach(function (alert) {
setTimeout(function () {
alert.classList.add('fade'); alert.classList.add('fade');
setTimeout(function() { setTimeout(function () {
alert.remove(); alert.remove();
}, 150); }, 150);
}, 5000); }, 5000);
}); });
// Confirm dialogs for dangerous actions
const confirmForms = document.querySelectorAll('form[data-confirm]'); const confirmForms = document.querySelectorAll('form[data-confirm]');
confirmForms.forEach(function(form) { confirmForms.forEach(function (form) {
form.addEventListener('submit', function(e) { form.addEventListener('submit', function (e) {
if (!confirm(form.dataset.confirm)) { if (!confirm(form.dataset.confirm)) {
e.preventDefault(); e.preventDefault();
} }
}); });
}); });
// Set default datetime to now + 1 day for new assignments
const dueDateInput = document.getElementById('due_date'); const dueDateInput = document.getElementById('due_date');
if (dueDateInput && !dueDateInput.value) { if (dueDateInput && !dueDateInput.value) {
const tomorrow = new Date(); const tomorrow = new Date();

View File

@@ -98,6 +98,11 @@
<li><code>PUT /api/v1/assignments/:id</code> - 課題更新</li> <li><code>PUT /api/v1/assignments/:id</code> - 課題更新</li>
<li><code>DELETE /api/v1/assignments/:id</code> - 課題削除</li> <li><code>DELETE /api/v1/assignments/:id</code> - 課題削除</li>
<li><code>PATCH /api/v1/assignments/:id/toggle</code> - 完了状態切替</li> <li><code>PATCH /api/v1/assignments/:id/toggle</code> - 完了状態切替</li>
<li><code>GET /api/v1/statistics</code> - 統計情報取得</li>
<li><code>GET /api/v1/recurring</code> - 繰り返し設定一覧取得</li>
<li><code>GET /api/v1/recurring/:id</code> - 繰り返し設定詳細取得</li>
<li><code>PUT /api/v1/recurring/:id</code> - 繰り返し設定更新</li>
<li><code>DELETE /api/v1/recurring/:id</code> - 繰り返し設定削除</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -56,13 +56,12 @@
重要度により間隔が変わります:大=10分、中=30分、小=1時間 重要度により間隔が変わります:大=10分、中=30分、小=1時間
</div> </div>
<hr class="my-2"> <hr class="my-2">
<!-- 1回リマインダー -->
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="reminder_enabled" <input class="form-check-input" type="checkbox" id="reminder_enabled"
name="reminder_enabled" {{if .assignment.ReminderEnabled}}checked{{end}} name="reminder_enabled" {{if .assignment.ReminderEnabled}}checked{{end}}
onchange="toggleReminderDate(this)"> onchange="toggleReminderDate(this)">
<label class="form-check-label" for="reminder_enabled"> <label class="form-check-label" for="reminder_enabled">
1回リマインダー(指定日時に1回通知) リマインダー(指定日時に通知)
</label> </label>
</div> </div>
<div class="mt-2" id="reminder_at_group" <div class="mt-2" id="reminder_at_group"
@@ -77,6 +76,39 @@
</div> </div>
</div> </div>
</div> </div>
{{if .recurring}}
<!-- 繰り返し設定 -->
<div class="card bg-light mb-3">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0"><i class="bi bi-arrow-repeat me-1"></i>繰り返し設定</h6>
<a href="/recurring/{{.recurring.ID}}/edit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil me-1"></i>編集
</a>
</div>
<div class="row">
<div class="col-6">
<small class="text-muted">タイプ</small>
<div class="fw-bold">
{{if eq .recurring.RecurrenceType "daily"}}毎日{{end}}
{{if eq .recurring.RecurrenceType "weekly"}}毎週{{end}}
{{if eq .recurring.RecurrenceType "monthly"}}毎月{{end}}
</div>
</div>
<div class="col-6">
<small class="text-muted">状態</small>
<div>
{{if .recurring.IsActive}}
<span class="badge bg-success">有効</span>
{{else}}
<span class="badge bg-secondary">停止中</span>
{{end}}
</div>
</div>
</div>
</div>
</div>
{{end}}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button> <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a> <a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>

View File

@@ -18,19 +18,50 @@
<!-- Tabs --> <!-- Tabs -->
<ul class="nav nav-tabs border-0 mb-2" id="assignmentTabs"> <ul class="nav nav-tabs border-0 mb-2" id="assignmentTabs">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " pending"}}fw-bold border-bottom border-dark <a class="nav-link py-2 rounded-0 {{if eq .filter "pending"}}fw-bold border-bottom border-dark border-3
border-3{{else}}text-muted{{end}}" href="/assignments?filter=pending&q={{.query}}&priority={{.priority}}" text-dark{{else}}border-0 text-muted{{end}}"
style="{{if eq .filter " pending"}}color: black !important;{{end}}">未完了</a> href="/assignments?filter=pending&q={{.query}}&priority={{.priority}}">
未完了
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " completed"}}fw-bold border-bottom border-dark <a class="nav-link py-2 rounded-0 {{if eq .filter "due_today"}}fw-bold border-bottom border-dark border-3
border-3{{else}}text-muted{{end}}" href="/assignments?filter=completed&q={{.query}}&priority={{.priority}}" text-dark{{else}}border-0 text-muted{{end}}"
style="{{if eq .filter " completed"}}color: black !important;{{end}}">完了済み</a> href="/assignments?filter=due_today&q={{.query}}&priority={{.priority}}">
今日が期限
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " overdue"}}fw-bold border-bottom border-dark <a class="nav-link py-2 rounded-0 {{if eq .filter "due_this_week"}}fw-bold border-bottom border-dark border-3
border-3{{else}}text-muted{{end}}" href="/assignments?filter=overdue&q={{.query}}&priority={{.priority}}" text-dark{{else}}border-0 text-muted{{end}}"
style="{{if eq .filter " overdue"}}color: black !important;{{end}}">期限切れ</a> href="/assignments?filter=due_this_week&q={{.query}}&priority={{.priority}}">
今週が期限
</a>
</li>
<li class="nav-item">
<a class="nav-link py-2 rounded-0 {{if eq .filter "completed"}}fw-bold border-bottom border-dark border-3
text-dark{{else}}border-0 text-muted{{end}}"
href="/assignments?filter=completed&q={{.query}}&priority={{.priority}}">
完了済み
</a>
</li>
<li class="nav-item">
<a class="nav-link py-2 rounded-0 {{if eq .filter "overdue"}}fw-bold border-bottom border-dark border-3
text-dark{{else}}border-0 text-muted{{end}}"
href="/assignments?filter=overdue&q={{.query}}&priority={{.priority}}">
期限切れ
</a>
</li>
<li class="nav-item">
<a class="nav-link py-2 rounded-0 border-0 text-muted"
href="/recurring">
繰り返し
</a>
</li> </li>
</ul> </ul>
<hr class="mt-0 mb-3 text-muted" style="opacity: 0.1;"> <hr class="mt-0 mb-3 text-muted" style="opacity: 0.1;">
@@ -68,9 +99,9 @@
<thead class="bg-secondary-subtle"> <thead class="bg-secondary-subtle">
<tr> <tr>
<th style="width: 50px;" class="ps-3 text-center text-dark fw-bold">状態</th> <th style="width: 50px;" class="ps-3 text-center text-dark fw-bold">状態</th>
<th class="text-dark fw-bold">タイトル</th>
<th style="width: 120px;" class="text-dark fw-bold">科目</th> <th style="width: 120px;" class="text-dark fw-bold">科目</th>
<th style="width: 80px;" class="text-dark fw-bold">重要度</th> <th style="width: 80px;" class="text-dark fw-bold">重要度</th>
<th class="text-dark fw-bold">タイトル</th>
<th style="width: 140px;" class="text-dark fw-bold">期限</th> <th style="width: 140px;" class="text-dark fw-bold">期限</th>
<th style="width: 120px;" class="countdown-col text-dark fw-bold">残り</th> <th style="width: 120px;" class="countdown-col text-dark fw-bold">残り</th>
<th style="width: 80px;" class="text-end pe-3 text-dark fw-bold">操作</th> <th style="width: 80px;" class="text-end pe-3 text-dark fw-bold">操作</th>
@@ -101,9 +132,6 @@
</form> </form>
{{end}} {{end}}
</td> </td>
<td>
<div class="fw-bold text-dark text-truncate" style="max-width: 300px;">{{.Title}}</div>
</td>
<td><span class="badge bg-secondary text-white border-0 fw-bold">{{.Subject}}</span></td> <td><span class="badge bg-secondary text-white border-0 fw-bold">{{.Subject}}</span></td>
<td> <td>
{{if eq .Priority "high"}} {{if eq .Priority "high"}}
@@ -114,6 +142,22 @@
<span class="badge bg-dark text-white border-0 fw-bold small"></span> <span class="badge bg-dark text-white border-0 fw-bold small"></span>
{{end}} {{end}}
</td> </td>
<td>
<div class="d-flex align-items-center">
<div class="fw-bold text-dark text-truncate" style="max-width: 280px;">{{.Title}}</div>
{{if .RecurringAssignmentID}}
<button type="button" class="btn btn-link p-0 ms-2 text-info" data-bs-toggle="modal"
data-bs-target="#recurringModal" data-recurring-id="{{.RecurringAssignmentID}}"
data-assignment-id="{{.ID}}"
data-recurring-title="{{.Title}}"
data-recurring-type="{{if .RecurringAssignment}}{{.RecurringAssignment.RecurrenceType}}{{else}}unknown{{end}}"
data-recurring-active="{{if .RecurringAssignment}}{{.RecurringAssignment.IsActive}}{{else}}true{{end}}"
title="繰り返し課題">
<i class="fa-solid fa-repeat"></i>
</button>
{{end}}
</div>
</td>
<td> <td>
<div class="small fw-bold text-dark user-select-all">{{.DueDate.Format "2006/01/02 15:04"}} <div class="small fw-bold text-dark user-select-all">{{.DueDate.Format "2006/01/02 15:04"}}
</div> </div>
@@ -130,6 +174,12 @@
<a href="/assignments/{{.ID}}/edit" class="text-primary me-3 text-decoration-none"> <a href="/assignments/{{.ID}}/edit" class="text-primary me-3 text-decoration-none">
<i class="bi bi-pencil-fill"></i> <i class="bi bi-pencil-fill"></i>
</a> </a>
{{if .RecurringAssignmentID}}
<button type="button" class="btn btn-link p-0 text-danger text-decoration-none border-0 bg-transparent"
onclick="showDeleteRecurringModal({{.ID}}, {{.RecurringAssignmentID}})">
<i class="bi bi-trash-fill"></i>
</button>
{{else}}
<form action="/assignments/{{.ID}}/delete" method="POST" class="d-inline" <form action="/assignments/{{.ID}}/delete" method="POST" class="d-inline"
onsubmit="return confirm('削除しますか?');"> onsubmit="return confirm('削除しますか?');">
<input type="hidden" name="_csrf" value="{{$.csrfToken}}"> <input type="hidden" name="_csrf" value="{{$.csrfToken}}">
@@ -138,6 +188,7 @@
<i class="bi bi-trash-fill"></i> <i class="bi bi-trash-fill"></i>
</button> </button>
</form> </form>
{{end}}
</div> </div>
</td> </td>
</tr> </tr>
@@ -266,5 +317,126 @@
const btnText = document.getElementById('countdownBtnText'); const btnText = document.getElementById('countdownBtnText');
if (btnText) btnText.textContent = 'カウントダウン非表示中'; if (btnText) btnText.textContent = 'カウントダウン非表示中';
} }
// Recurring modal handler - wait for DOM to be ready
document.addEventListener('DOMContentLoaded', function() {
const recurringModal = document.getElementById('recurringModal');
if (recurringModal) {
recurringModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
const id = button.getAttribute('data-recurring-id');
const assignmentId = button.getAttribute('data-assignment-id');
const title = button.getAttribute('data-recurring-title');
const type = button.getAttribute('data-recurring-type');
const isActive = button.getAttribute('data-recurring-active') === 'true';
document.getElementById('recurringModalTitle').textContent = title;
document.getElementById('recurringStopForm').action = '/recurring/' + id + '/stop';
document.getElementById('recurringEditBtn').href = '/recurring/' + id + '/edit';
const typeLabels = {
'daily': '毎日',
'weekly': '毎週',
'monthly': '毎月',
'unknown': '(読み込み中...)'
};
document.getElementById('recurringTypeLabel').textContent = typeLabels[type] || type || '不明';
const statusEl = document.getElementById('recurringStatus');
if (isActive) {
statusEl.innerHTML = '<span class="badge bg-success">有効</span>';
document.getElementById('recurringStopBtn').style.display = 'inline-block';
} else {
statusEl.innerHTML = '<span class="badge bg-secondary">停止中</span>';
document.getElementById('recurringStopBtn').style.display = 'none';
}
});
}
});
</script>
<!-- Recurring Modal -->
<div class="modal fade" id="recurringModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-repeat me-2"></i>繰り返し課題</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<h6 id="recurringModalTitle" class="mb-3 fw-bold"></h6>
<table class="table table-sm table-borderless">
<tbody>
<tr>
<th class="text-muted" style="width: 100px;">繰り返し</th>
<td id="recurringTypeLabel">読み込み中...</td>
</tr>
<tr>
<th class="text-muted">状態</th>
<td id="recurringStatus">読み込み中...</td>
</tr>
</tbody>
</table>
<div class="alert alert-info small mb-0">
<i class="bi bi-info-circle me-1"></i>
繰り返しを停止すると、今後新しい課題は自動作成されなくなります。既存の課題はそのまま残ります。
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">閉じる</button>
<a id="recurringEditBtn" href="#" class="btn btn-primary">
<i class="bi bi-pencil me-1"></i>編集
</a>
<form id="recurringStopForm" method="POST" class="d-inline">
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
<button type="submit" id="recurringStopBtn" class="btn btn-danger"
onclick="return confirm('繰り返しを停止しますか?');">
<i class="bi bi-stop-fill me-1"></i>停止
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Delete Recurring Confirmation Modal -->
<div class="modal fade" id="deleteRecurringModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-trash me-2"></i>繰り返し課題の削除</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>この課題は繰り返し設定に関連付けられています。</p>
<p>繰り返し設定も停止しますか?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">キャンセル</button>
<form id="deleteOnlyForm" method="POST" class="d-inline">
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
<button type="submit" class="btn btn-outline-danger">
課題のみ削除
</button>
</form>
<form id="deleteAndStopForm" method="POST" class="d-inline">
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
<button type="submit" class="btn btn-danger">
削除して繰り返しも削除
</button>
</form>
</div>
</div>
</div>
</div>
<script>
function showDeleteRecurringModal(assignmentId, recurringId) {
var modal = new bootstrap.Modal(document.getElementById('deleteRecurringModal'));
document.getElementById('deleteOnlyForm').action = '/assignments/' + assignmentId + '/delete';
document.getElementById('deleteAndStopForm').action = '/assignments/' + assignmentId + '/delete?stop_recurring=' + recurringId;
modal.show();
}
</script> </script>
{{end}} {{end}}

View File

@@ -55,12 +55,11 @@
重要度により間隔が変わります:大=10分、中=30分、小=1時間 重要度により間隔が変わります:大=10分、中=30分、小=1時間
</div> </div>
<hr class="my-2"> <hr class="my-2">
<!-- 1回リマインダー -->
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="reminder_enabled" <input class="form-check-input" type="checkbox" id="reminder_enabled"
name="reminder_enabled" onchange="toggleReminderDate(this)"> name="reminder_enabled" onchange="toggleReminderDate(this)">
<label class="form-check-label" for="reminder_enabled"> <label class="form-check-label" for="reminder_enabled">
1回リマインダー(指定日時に1回通知) リマインダー(指定日時に通知)
</label> </label>
</div> </div>
<div class="mt-2" id="reminder_at_group" style="display: none;"> <div class="mt-2" id="reminder_at_group" style="display: none;">
@@ -70,18 +69,129 @@
</div> </div>
</div> </div>
</div> </div>
<div class="d-flex gap-2"> <div class="card bg-light mb-3">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>登録</button> <div class="card-header py-2" style="cursor: pointer;" data-bs-toggle="collapse"
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a> data-bs-target="#recurringSettings">
<h6 class="mb-0"><i class="bi bi-arrow-repeat me-1"></i>繰り返し設定 <i
class="bi bi-chevron-down float-end"></i></h6>
</div>
<div class="collapse" id="recurringSettings">
<div class="card-body py-2">
<div class="row mb-2">
<div class="col-6">
<label for="recurrence_type" class="form-label small">繰り返しタイプ</label>
<select class="form-select form-select-sm" id="recurrence_type"
name="recurrence_type" onchange="updateRecurrenceOptions()">
<option value="none" selected>なし</option>
<option value="daily">毎日</option>
<option value="weekly">毎週</option>
<option value="monthly">毎月</option>
</select>
</div>
<div class="col-6" id="interval_group" style="display: none;">
<label for="recurrence_interval" class="form-label small">間隔</label>
<div class="input-group input-group-sm">
<input type="number" class="form-control" id="recurrence_interval"
name="recurrence_interval" value="1" min="1" max="12">
<span class="input-group-text" id="interval_label"></span>
</div>
</div>
</div>
<div id="weekday_group" style="display: none;" class="mb-2">
<label class="form-label small">曜日</label>
<div class="btn-group btn-group-sm w-100" role="group">
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd0"
value="0" {{if eq .currentWeekday 0}}checked{{end}}>
<label class="btn btn-outline-primary" for="wd0"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd1"
value="1" {{if eq .currentWeekday 1}}checked{{end}}>
<label class="btn btn-outline-primary" for="wd1"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd2"
value="2" {{if eq .currentWeekday 2}}checked{{end}}>
<label class="btn btn-outline-primary" for="wd2"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd3"
value="3" {{if eq .currentWeekday 3}}checked{{end}}>
<label class="btn btn-outline-primary" for="wd3"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd4"
value="4" {{if eq .currentWeekday 4}}checked{{end}}>
<label class="btn btn-outline-primary" for="wd4"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd5"
value="5" {{if eq .currentWeekday 5}}checked{{end}}>
<label class="btn btn-outline-primary" for="wd5"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd6"
value="6" {{if eq .currentWeekday 6}}checked{{end}}>
<label class="btn btn-outline-primary" for="wd6"></label>
</div>
</div>
<div id="day_group" style="display: none;" class="mb-2">
<label for="recurrence_day" class="form-label small"></label>
<select class="form-select form-select-sm" id="recurrence_day"
name="recurrence_day">
{{range $i := seq 1 31}}
<option value="{{$i}}" {{if eq $.currentDay $i}}selected{{end}}>{{$i}}日</option>
{{end}}
</select>
</div>
<div id="end_group" style="display: none;">
<label class="form-label small">終了条件</label>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="end_type" id="end_never"
value="never" checked>
<label class="form-check-label small" for="end_never">無期限</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="end_type" id="end_count"
value="count">
<label class="form-check-label small" for="end_count">回数</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="end_type" id="end_date"
value="date">
<label class="form-check-label small" for="end_date">終了日</label>
</div>
<div class="mt-1" id="end_count_group" style="display: none;">
<input type="number" class="form-control form-control-sm" id="end_count_value"
name="end_count" value="10" min="1" style="width: 100px;">
</div>
<div class="mt-1" id="end_date_group" style="display: none;">
<input type="date" class="form-control form-control-sm" id="end_date_value"
name="end_date" style="width: 150px;">
</div>
</div>
</div>
</div>
</div> </div>
</form>
</div> </div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>登録</button>
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
</div>
<script> <script>
function toggleReminderDate(checkbox) { function toggleReminderDate(checkbox) {
document.getElementById('reminder_at_group').style.display = checkbox.checked ? 'block' : 'none'; document.getElementById('reminder_at_group').style.display = checkbox.checked ? 'block' : 'none';
} }
function updateRecurrenceOptions() {
const type = document.getElementById('recurrence_type').value;
const isRecurring = type !== 'none';
document.getElementById('interval_group').style.display = isRecurring ? 'block' : 'none';
document.getElementById('weekday_group').style.display = type === 'weekly' ? 'block' : 'none';
document.getElementById('day_group').style.display = type === 'monthly' ? 'block' : 'none';
document.getElementById('end_group').style.display = isRecurring ? 'block' : 'none';
const label = document.getElementById('interval_label');
if (type === 'daily') label.textContent = '日';
else if (type === 'weekly') label.textContent = '週';
else if (type === 'monthly') label.textContent = '月';
}
document.querySelectorAll('input[name="end_type"]').forEach(radio => {
radio.addEventListener('change', function () {
document.getElementById('end_count_group').style.display = this.value === 'count' ? 'block' : 'none';
document.getElementById('end_date_group').style.display = this.value === 'date' ? 'block' : 'none';
});
});
</script> </script>
{{end}} {{end}}

View File

@@ -30,7 +30,76 @@
.pagination-info { .pagination-info {
font-size: 0.875rem; font-size: 0.875rem;
} }
.stats-table {
min-width: 700px;
}
.stats-table th,
.stats-table td {
white-space: nowrap;
}
.stats-table th:first-child,
.stats-table td:first-child {
min-width: 120px;
}
#shareCard {
width: 600px;
height: 315px;
position: fixed;
left: -9999px;
top: 0;
background: linear-gradient(135deg, #005bea 0%, #00c6fb 100%);
padding: 2rem;
color: white;
border-radius: 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
#shareCard .card-title {
font-size: 1.5rem;
font-weight: bold;
opacity: 0.9;
}
#shareCard .rate-display {
font-size: 5rem;
font-weight: bold;
line-height: 1;
margin: 1rem 0;
}
#shareCard .stats-row {
display: flex;
justify-content: space-around;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 1rem;
backdrop-filter: blur(5px);
}
#shareCard .stat-item {
text-align: center;
}
#shareCard .stat-label {
font-size: 0.8rem;
opacity: 0.8;
display: block;
margin-bottom: 0.2rem;
}
#shareCard .stat-value {
font-size: 1.4rem;
font-weight: bold;
}
</style> </style>
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
{{end}} {{end}}
{{define "content"}} {{define "content"}}
@@ -130,8 +199,11 @@
</div> </div>
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header d-flex justify-content-between align-items-center">
<i class="bi bi-clock-history me-2"></i>期限内完了率 <span><i class="bi bi-clock-history me-2"></i>期限内完了率</span>
<button class="btn btn-sm btn-outline-primary" onclick="generateShareImage()">
<i class="bi bi-share me-1"></i>シェア
</button>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row align-items-center"> <div class="row align-items-center">
@@ -174,7 +246,7 @@
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0 stats-table">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>科目</th> <th>科目</th>
@@ -206,7 +278,7 @@
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0 stats-table">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>科目</th> <th>科目</th>
@@ -238,6 +310,69 @@
<p class="text-muted">課題を登録して科目を設定すると、ここに統計が表示されます。</p> <p class="text-muted">課題を登録して科目を設定すると、ここに統計が表示されます。</p>
</div> </div>
</div> </div>
<div id="shareCard">
<div>
<div class="d-flex align-items-center mb-4">
<i class="bi bi-journal-check me-2" style="font-size: 1.5rem;"></i>
<span class="card-title">Super Homework Manager</span>
</div>
</div>
<div class="text-center">
<div style="font-size: 1.5rem; font-weight: bold; margin-bottom: 0.5rem; opacity: 0.9;">期限内完了率</div>
<div class="rate-display" style="margin-top: 0;">
{{printf "%.1f" .stats.OnTimeCompletionRate}}<span style="font-size: 2.5rem;">%</span>
</div>
</div>
<div class="stats-row">
<div class="stat-item">
<span class="stat-label">完了</span>
<span class="stat-value">{{.stats.CompletedAssignments}}</span>
</div>
<div class="stat-item">
<span class="stat-label">未完了</span>
<span class="stat-value">{{.stats.PendingAssignments}}</span>
</div>
<div class="stat-item">
<span class="stat-label">期限切れ</span>
<span class="stat-value">{{.stats.OverdueAssignments}}</span>
</div>
</div>
</div>
<!-- Share Modal -->
<div class="modal fade" id="shareModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-share me-2"></i>シェア</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<div id="sharePreviewContainer" class="mb-3" style="max-width: 100%; overflow: hidden;">
</div>
<p class="text-muted small mb-3">
画像を保存またはコピーして、SNSに貼り付けてください。<br>
<span class="text-danger"><i class="bi bi-info-circle me-1"></i>ブラウザの制限により、自動で画像は添付されません。</span>
</p>
<div class="d-grid gap-2">
<button class="btn btn-outline-primary" onclick="copyImageToClipboard(this)">
<i class="bi bi-clipboard me-2"></i>画像をコピー
</button>
<a id="downloadLink" class="btn btn-outline-secondary" download="stats.png">
<i class="bi bi-download me-2"></i>画像を保存
</a>
<a id="twitterShareBtn" href="#" target="_blank" class="btn btn-dark"
style="background-color: #000;">
<i class="bi bi-twitter-x me-2"></i>Xでポストする
</a>
</div>
</div>
</div>
</div>
</div>
{{end}} {{end}}
{{define "scripts"}} {{define "scripts"}}
@@ -255,6 +390,88 @@
var activePage = 1; var activePage = 1;
var archivedPage = 1; var archivedPage = 1;
// Share Functionality
window.generateShareImage = function () {
var card = document.getElementById('shareCard');
// Ensure card is visible for rendering but off-screen
card.style.display = 'flex';
html2canvas(card, {
backgroundColor: null,
scale: 2 // High resolution
}).then(canvas => {
var imgData = canvas.toDataURL('image/png');
// Set up preview
var previewContainer = document.getElementById('sharePreviewContainer');
previewContainer.innerHTML = '';
var img = document.createElement('img');
img.src = imgData;
img.style.maxWidth = '100%';
img.style.borderRadius = '8px';
img.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
previewContainer.appendChild(img);
// Set up download link
var downloadLink = document.getElementById('downloadLink');
downloadLink.href = imgData;
// Set up Twitter button
var text = '私の課題完了状況\n期限内完了率: {{printf "%.1f" .stats.OnTimeCompletionRate}}%\n#SuperHomeworkManager';
var twitterBtn = document.getElementById('twitterShareBtn');
twitterBtn.href = "https://twitter.com/intent/tweet?text=" + encodeURIComponent(text);
// Show modal
var modal = new bootstrap.Modal(document.getElementById('shareModal'));
modal.show();
});
};
// Helper to convert Data URL to Blob
function dataURLtoBlob(dataurl) {
var arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
}
window.copyImageToClipboard = function (btn) {
var canvas = document.querySelector('#sharePreviewContainer img');
if (!canvas) return;
if (!navigator.clipboard) {
alert('このブラウザまたは環境非HTTPS/非localhostでは、クリップボードへのコピー機能がサポートされていません。\n「画像を保存」を使用してください。');
return;
}
try {
var blob = dataURLtoBlob(canvas.src);
navigator.clipboard.write([
new ClipboardItem({
'image/png': blob
})
]).then(function () {
var originalText = btn.innerHTML;
btn.innerHTML = '<i class="bi bi-check me-2"></i>コピーしました';
btn.classList.remove('btn-outline-primary');
btn.classList.add('btn-success');
setTimeout(function () {
btn.innerHTML = originalText;
btn.classList.add('btn-outline-primary');
btn.classList.remove('btn-success');
}, 2000);
}).catch(function (err) {
console.error('Failed to copy: ', err);
alert('画像のコピーに失敗しました。\nエラー: ' + err.message + '\n「画像を保存」を使用してください。');
});
} catch (err) {
console.error('Failed to create blob: ', err);
alert('画像データの生成に失敗しました: ' + err.message);
}
};
function getRateClass(rate) { function getRateClass(rate) {
if (rate >= 80) return 'text-success'; if (rate >= 80) return 'text-success';
if (rate >= 50) return 'text-warning'; if (rate >= 50) return 'text-warning';

View File

@@ -1,5 +1,11 @@
{{template "base" .}} {{template "base" .}}
{{define "head"}}
{{if and .captchaEnabled (eq .captchaType "turnstile")}}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{{end}}
{{end}}
{{define "content"}} {{define "content"}}
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-5 col-lg-4"> <div class="col-md-5 col-lg-4">
@@ -25,6 +31,28 @@
<label for="password" class="form-label">パスワード</label> <label for="password" class="form-label">パスワード</label>
<input type="password" class="form-control" id="password" name="password" required> <input type="password" class="form-control" id="password" name="password" required>
</div> </div>
{{if .captchaEnabled}}
{{if eq .captchaType "turnstile"}}
<div class="mb-3 d-flex justify-content-center">
<div class="cf-turnstile" data-sitekey="{{.turnstileSiteKey}}"></div>
</div>
{{else if eq .captchaType "image"}}
<div class="mb-3">
<label class="form-label">画像認証</label>
<div class="d-flex align-items-center gap-2 mb-2">
<img src="/captcha/{{.captchaID}}.png" alt="CAPTCHA" class="border rounded" id="captchaImg">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="reloadCaptcha()" title="更新">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<input type="hidden" name="captcha_id" id="captchaID" value="{{.captchaID}}">
<input type="text" class="form-control" name="captcha_answer"
placeholder="上の数字を入力" autocomplete="off" required>
</div>
{{end}}
{{end}}
<div class="d-grid"> <div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">ログイン</button> <button type="submit" class="btn btn-primary btn-lg">ログイン</button>
</div> </div>
@@ -40,4 +68,19 @@
</div> </div>
</div> </div>
</div> </div>
{{end}} {{end}}
{{define "scripts"}}
{{if and .captchaEnabled (eq .captchaType "image")}}
<script>
function reloadCaptcha() {
fetch('/captcha-new')
.then(r => r.text())
.then(id => {
document.getElementById('captchaID').value = id;
document.getElementById('captchaImg').src = '/captcha/' + id + '.png?' + Date.now();
});
}
</script>
{{end}}
{{end}}

View File

@@ -0,0 +1,46 @@
{{template "base" .}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-md-5 col-lg-4">
<div class="card shadow">
<div class="card-body p-4">
<div class="text-center mb-4">
<i class="bi bi-shield-lock display-4 text-primary"></i>
<h2 class="mt-2">2段階認証</h2>
<p class="text-muted small">認証アプリに表示されている6桁のコードを入力してください</p>
</div>
{{if .error}}
<div class="alert alert-danger">{{.error}}</div>
{{end}}
<form method="POST" action="/login/2fa">
{{.csrfField}}
<div class="mb-4">
<label for="totp_code" class="form-label">認証コード</label>
<input type="text" class="form-control form-control-lg text-center"
id="totp_code" name="totp_code"
placeholder="000000"
maxlength="6" pattern="[0-9]{6}"
inputmode="numeric" autocomplete="one-time-code"
autofocus required>
<div class="form-text text-center">Google Authenticator などのアプリで確認</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-shield-check me-1"></i>確認
</button>
</div>
</form>
<hr class="my-4">
<div class="text-center">
<a href="/login" class="text-muted small">別のアカウントでログイン</a>
</div>
</div>
</div>
</div>
</div>
{{end}}

View File

@@ -1,5 +1,11 @@
{{template "base" .}} {{template "base" .}}
{{define "head"}}
{{if and .captchaEnabled (eq .captchaType "turnstile")}}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{{end}}
{{end}}
{{define "content"}} {{define "content"}}
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-5 col-lg-4"> <div class="col-md-5 col-lg-4">
@@ -28,14 +34,36 @@
<div class="mb-3"> <div class="mb-3">
<label for="password" class="form-label">パスワード</label> <label for="password" class="form-label">パスワード</label>
<input type="password" class="form-control" id="password" name="password" required <input type="password" class="form-control" id="password" name="password" required
minlength="6"> minlength="8">
<div class="form-text">6文字以上</div> <div class="form-text">8文字以上</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="password_confirm" class="form-label">パスワード(確認)</label> <label for="password_confirm" class="form-label">パスワード(確認)</label>
<input type="password" class="form-control" id="password_confirm" name="password_confirm" <input type="password" class="form-control" id="password_confirm" name="password_confirm"
required minlength="6"> required minlength="8">
</div> </div>
{{if .captchaEnabled}}
{{if eq .captchaType "turnstile"}}
<div class="mb-3 d-flex justify-content-center">
<div class="cf-turnstile" data-sitekey="{{.turnstileSiteKey}}"></div>
</div>
{{else if eq .captchaType "image"}}
<div class="mb-3">
<label class="form-label">画像認証</label>
<div class="d-flex align-items-center gap-2 mb-2">
<img src="/captcha/{{.captchaID}}.png" alt="CAPTCHA" class="border rounded" id="captchaImg">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="reloadCaptcha()" title="更新">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<input type="hidden" name="captcha_id" id="captchaID" value="{{.captchaID}}">
<input type="text" class="form-control" name="captcha_answer"
placeholder="上の数字を入力" autocomplete="off" required>
</div>
{{end}}
{{end}}
<div class="d-grid"> <div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">登録</button> <button type="submit" class="btn btn-primary btn-lg">登録</button>
</div> </div>
@@ -51,4 +79,19 @@
</div> </div>
</div> </div>
</div> </div>
{{end}} {{end}}
{{define "scripts"}}
{{if and .captchaEnabled (eq .captchaType "image")}}
<script>
function reloadCaptcha() {
fetch('/captcha-new')
.then(r => r.text())
.then(id => {
document.getElementById('captchaID').value = id;
document.getElementById('captchaImg').src = '/captcha/' + id + '.png?' + Date.now();
});
}
</script>
{{end}}
{{end}}

View File

@@ -8,6 +8,7 @@
<title>{{.title}} - Super Homework Manager</title> <title>{{.title}} - Super Homework Manager</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5.1/css/all.min.css" rel="stylesheet">
<link href="/static/css/style.css" rel="stylesheet"> <link href="/static/css/style.css" rel="stylesheet">
<style> <style>
.navbar-dark .navbar-nav .nav-link, .navbar-dark .navbar-nav .nav-link,

View File

@@ -65,6 +65,16 @@
font-size: 1.5rem; font-size: 1.5rem;
font-weight: bold; font-weight: bold;
} }
.dashboard-stat-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
}
.dashboard-stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
</style> </style>
{{end}} {{end}}
@@ -74,7 +84,7 @@
<i class="bi bi-exclamation-octagon-fill me-2"></i> <i class="bi bi-exclamation-octagon-fill me-2"></i>
<span id="urgentMessage"></span> <span id="urgentMessage"></span>
<div class="urgent-countdown mt-1"> <div class="urgent-countdown mt-1">
<i class="bi bi-stopwatch"></i> あと <span id="urgentCountdown"></span> <i class="bi bi-stopwatch"></i> <span id="urgentCountdown"></span>
</div> </div>
</div> </div>
</div> </div>
@@ -83,56 +93,64 @@
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
<div class="card bg-primary text-white h-100"> <a href="/assignments?filter=pending" class="text-decoration-none">
<div class="card-body"> <div class="card bg-primary text-white h-100 dashboard-stat-card">
<div class="d-flex justify-content-between align-items-center"> <div class="card-body">
<div> <div class="d-flex justify-content-between align-items-center">
<h6 class="text-white-50">未完了の課題</h6> <div>
<h2 class="mb-0">{{.stats.TotalPending}}</h2> <h6 class="text-white-50">未完了の課題</h6>
<h2 class="mb-0">{{.stats.TotalPending}}</h2>
</div>
<i class="bi bi-list-task display-4 opacity-50"></i>
</div> </div>
<i class="bi bi-list-task display-4 opacity-50"></i>
</div> </div>
</div> </div>
</div> </a>
</div> </div>
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
<div class="card bg-warning text-dark h-100"> <a href="/assignments?filter=due_today" class="text-decoration-none">
<div class="card-body"> <div class="card bg-warning text-dark h-100 dashboard-stat-card">
<div class="d-flex justify-content-between align-items-center"> <div class="card-body">
<div> <div class="d-flex justify-content-between align-items-center">
<h6 class="text-dark-50">今日が期限</h6> <div>
<h2 class="mb-0">{{.stats.DueToday}}</h2> <h6 class="text-dark-50">今日が期限</h6>
<h2 class="mb-0">{{.stats.DueToday}}</h2>
</div>
<i class="bi bi-calendar-event display-4 opacity-50"></i>
</div> </div>
<i class="bi bi-calendar-event display-4 opacity-50"></i>
</div> </div>
</div> </div>
</div> </a>
</div> </div>
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
<div class="card bg-info text-white h-100"> <a href="/assignments?filter=due_this_week" class="text-decoration-none">
<div class="card-body"> <div class="card bg-info text-white h-100 dashboard-stat-card">
<div class="d-flex justify-content-between align-items-center"> <div class="card-body">
<div> <div class="d-flex justify-content-between align-items-center">
<h6 class="text-white-50">今週が期限</h6> <div>
<h2 class="mb-0">{{.stats.DueThisWeek}}</h2> <h6 class="text-white-50">今週が期限</h6>
<h2 class="mb-0">{{.stats.DueThisWeek}}</h2>
</div>
<i class="bi bi-calendar-week display-4 opacity-50"></i>
</div> </div>
<i class="bi bi-calendar-week display-4 opacity-50"></i>
</div> </div>
</div> </div>
</div> </a>
</div> </div>
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
<div class="card bg-danger text-white h-100"> <a href="/assignments?filter=overdue" class="text-decoration-none">
<div class="card-body"> <div class="card bg-danger text-white h-100 dashboard-stat-card">
<div class="d-flex justify-content-between align-items-center"> <div class="card-body">
<div> <div class="d-flex justify-content-between align-items-center">
<h6 class="text-white-50">期限切れ</h6> <div>
<h2 class="mb-0">{{.stats.Overdue}}</h2> <h6 class="text-white-50">期限切れ</h6>
<h2 class="mb-0">{{.stats.Overdue}}</h2>
</div>
<i class="bi bi-exclamation-triangle display-4 opacity-50"></i>
</div> </div>
<i class="bi bi-exclamation-triangle display-4 opacity-50"></i>
</div> </div>
</div> </div>
</div> </a>
</div> </div>
</div> </div>
@@ -146,9 +164,9 @@
<li class="list-group-item d-flex justify-content-between align-items-center" <li class="list-group-item d-flex justify-content-between align-items-center"
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}"> data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
<div> <div>
{{if .Subject}}<span class="badge bg-secondary me-1">{{.Subject}}</span>{{end}}
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">重要</span>{{end}}
<strong>{{.Title}}</strong> <strong>{{.Title}}</strong>
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
<br><small class="text-danger">{{formatDateTime .DueDate}}</small> <br><small class="text-danger">{{formatDateTime .DueDate}}</small>
</div> </div>
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf" <form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
@@ -169,9 +187,9 @@
<li class="list-group-item d-flex justify-content-between align-items-center" <li class="list-group-item d-flex justify-content-between align-items-center"
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}"> data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
<div> <div>
{{if .Subject}}<span class="badge bg-secondary me-1">{{.Subject}}</span>{{end}}
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">重要</span>{{end}}
<strong>{{.Title}}</strong> <strong>{{.Title}}</strong>
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
<br><small class="text-muted">{{formatDateTime .DueDate}}</small> <br><small class="text-muted">{{formatDateTime .DueDate}}</small>
</div> </div>
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf" <form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
@@ -192,9 +210,9 @@
<li class="list-group-item d-flex justify-content-between align-items-center" <li class="list-group-item d-flex justify-content-between align-items-center"
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}"> data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
<div> <div>
{{if .Subject}}<span class="badge bg-secondary me-1">{{.Subject}}</span>{{end}}
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">重要</span>{{end}}
<strong>{{.Title}}</strong> <strong>{{.Title}}</strong>
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
<br><small class="text-muted">{{formatDateTime .DueDate}}</small> <br><small class="text-muted">{{formatDateTime .DueDate}}</small>
</div> </div>
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf" <form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
@@ -288,10 +306,10 @@
var mins = Math.floor((diff % 3600000) / 60000); var mins = Math.floor((diff % 3600000) / 60000);
var secs = Math.floor((diff % 60000) / 1000); var secs = Math.floor((diff % 60000) / 1000);
var text = ''; var text = 'あと ';
if (days > 0) text = days + '日 ' + hours + '時間 ' + mins + '分 ' + secs + '秒'; if (days > 0) text += days + '日 ' + hours + '時間 ' + mins + '分 ' + secs + '秒';
else if (hours > 0) text = hours + '時間 ' + mins + '分 ' + secs + '秒'; else if (hours > 0) text += hours + '時間 ' + mins + '分 ' + secs + '秒';
else text = mins + '分 ' + secs + '秒'; else text += mins + '分 ' + secs + '秒';
countdown.textContent = text; countdown.textContent = text;
} }

View File

@@ -67,6 +67,43 @@
</div> </div>
</div> </div>
<!-- 2段階認証設定 -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>2段階認証2FA</h5>
</div>
<div class="card-body">
{{if .totpError}}<div class="alert alert-danger">{{.totpError}}</div>{{end}}
{{if .totpSuccess}}<div class="alert alert-success">{{.totpSuccess}}</div>{{end}}
{{if .user.TOTPEnabled}}
<div class="d-flex align-items-center mb-3">
<span class="badge bg-success me-2"><i class="bi bi-check-lg me-1"></i>有効</span>
<span class="text-muted">2段階認証が有効になっています</span>
</div>
<form method="POST" action="/profile/totp/disable">
{{.csrfField}}
<div class="mb-3">
<label for="totp_disable_password" class="form-label">現在のパスワードを入力して無効化</label>
<input type="password" class="form-control" id="totp_disable_password" name="password"
placeholder="パスワード" required style="max-width:320px">
</div>
<button type="submit" class="btn btn-danger">
<i class="bi bi-shield-x me-1"></i>2段階認証を無効化
</button>
</form>
{{else}}
<div class="d-flex align-items-center mb-3">
<span class="badge bg-secondary me-2"><i class="bi bi-x-lg me-1"></i>無効</span>
<span class="text-muted">2段階認証が設定されていません</span>
</div>
<p class="text-muted small">2段階認証を有効にするとセキュリティが向上します。Google Authenticator などのアプリが必要です。</p>
<a href="/profile/totp/setup" class="btn btn-primary">
<i class="bi bi-shield-plus me-1"></i>2段階認証を設定する
</a>
{{end}}
</div>
</div>
<!-- 通知設定 --> <!-- 通知設定 -->
<div class="card mt-4"> <div class="card mt-4">
<div class="card-header"> <div class="card-header">
@@ -95,22 +132,14 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6"> </div>
<h6 class="mb-3"><i class="bi bi-chat-dots me-1"></i>LINE Notify</h6> <hr class="my-3">
<div class="form-check form-switch mb-2"> <div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="line_enabled" name="line_enabled" <input class="form-check-input" type="checkbox" id="notify_on_create" name="notify_on_create"
{{if .notifySettings.LineEnabled}}checked{{end}}> {{if .notifySettings.NotifyOnCreate}}checked{{end}}>
<label class="form-check-label" for="line_enabled">LINE通知を有効化</label> <label class="form-check-label" for="notify_on_create">
</div> <i class="bi bi-plus-circle me-1"></i>課題追加時に通知する
<div class="mb-3"> </label>
<label for="line_token" class="form-label">アクセストークン</label>
<input type="password" class="form-control" id="line_token" name="line_token"
value="{{.notifySettings.LineNotifyToken}}" placeholder="トークンを入力">
<div class="form-text">
<a href="https://notify-bot.line.me/my/" target="_blank">LINE Notify</a>でトークンを発行
</div>
</div>
</div>
</div> </div>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>通知設定を保存</button> <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>通知設定を保存</button>
</form> </form>

View File

@@ -0,0 +1,83 @@
{{template "base" .}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-md-7 col-lg-6">
<div class="card shadow">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>2段階認証の設定</h5>
</div>
<div class="card-body p-4">
{{if .error}}
<div class="alert alert-danger">{{.error}}</div>
{{end}}
<div class="alert alert-info">
<i class="bi bi-info-circle me-1"></i>
Google Authenticator、Authy などの認証アプリを使用してください。
</div>
<h6 class="fw-bold mb-3">手順</h6>
<ol class="mb-4">
<li class="mb-2">認証アプリを開き、新しいアカウントを追加してください。</li>
<li class="mb-2">下のQRコードをスキャンするか、シークレットキーを手動で入力してください。</li>
<li class="mb-2">アプリに表示された6桁のコードを下の欄に入力して確認してください。</li>
</ol>
<div class="text-center mb-4">
<img src="data:image/png;base64,{{.qrCode}}" alt="QRコード" class="border rounded"
style="max-width:200px">
</div>
<div class="mb-4">
<label class="form-label fw-bold">シークレットキー(手動入力の場合)</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" id="secretKey" value="{{.secret}}"
readonly>
<button class="btn btn-outline-secondary" type="button" onclick="copySecret()" title="コピー">
<i class="bi bi-clipboard"></i>
</button>
</div>
<div class="form-text">認証アプリで「手動入力」を選択し、このキーを入力してください。</div>
</div>
<form method="POST" action="/profile/totp/setup">
{{.csrfField}}
<div class="mb-3">
<label for="totp_password" class="form-label fw-bold">現在のパスワード</label>
<input type="password" class="form-control" id="totp_password" name="password"
placeholder="パスワードを入力" required>
</div>
<div class="mb-3">
<label for="totp_code" class="form-label fw-bold">認証コードで確認</label>
<input type="text" class="form-control form-control-lg text-center" id="totp_code"
name="totp_code" placeholder="000000" maxlength="6" pattern="[0-9]{6}" inputmode="numeric"
autocomplete="off" autofocus required>
<div class="form-text">認証アプリに表示された6桁のコードを入力してください。</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-shield-check me-1"></i>有効化
</button>
<a href="/profile" class="btn btn-outline-secondary">キャンセル</a>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
function copySecret() {
const el = document.getElementById('secretKey');
el.select();
navigator.clipboard.writeText(el.value).then(() => {
const btn = el.nextElementSibling;
btn.innerHTML = '<i class="bi bi-check-lg"></i>';
setTimeout(() => { btn.innerHTML = '<i class="bi bi-clipboard"></i>'; }, 2000);
});
}
</script>
{{end}}

View File

@@ -0,0 +1,148 @@
{{template "base" .}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-arrow-repeat me-2"></i>繰り返し課題の編集</h5>
</div>
<div class="card-body">
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
<form method="POST" action="/recurring/{{.recurring.ID}}">
{{.csrfField}}
<div class="mb-3">
<label for="title" class="form-label">タイトル <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="title" name="title" value="{{.recurring.Title}}" required>
</div>
<div class="mb-3">
<label for="subject" class="form-label">科目</label>
<input type="text" class="form-control" id="subject" name="subject" value="{{.recurring.Subject}}">
</div>
<div class="mb-3">
<label for="priority" class="form-label">重要度</label>
<select class="form-select" id="priority" name="priority">
<option value="low" {{if eq .recurring.Priority "low"}}selected{{end}}></option>
<option value="medium" {{if eq .recurring.Priority "medium"}}selected{{end}}></option>
<option value="high" {{if eq .recurring.Priority "high"}}selected{{end}}></option>
</select>
</div>
<div class="mb-3">
<label for="description" class="form-label">説明</label>
<textarea class="form-control" id="description" name="description" rows="3">{{.recurring.Description}}</textarea>
</div>
<div class="mb-3">
<label for="due_time" class="form-label">時刻</label>
<input type="time" class="form-control" id="due_time" name="due_time" value="{{.recurring.DueTime}}">
</div>
<div class="card bg-light mb-3">
<div class="card-body py-3">
<h6 class="mb-3"><i class="bi bi-arrow-repeat me-1"></i>繰り返し設定</h6>
<div class="row mb-3">
<div class="col-6">
<label for="recurrence_type" class="form-label small">繰り返しタイプ</label>
<select class="form-select form-select-sm" id="recurrence_type" name="recurrence_type" onchange="updateRecurrenceOptions()">
<option value="daily" {{if eq .recurring.RecurrenceType "daily"}}selected{{end}}>毎日</option>
<option value="weekly" {{if eq .recurring.RecurrenceType "weekly"}}selected{{end}}>毎週</option>
<option value="monthly" {{if eq .recurring.RecurrenceType "monthly"}}selected{{end}}>毎月</option>
</select>
</div>
<div class="col-6">
<label for="recurrence_interval" class="form-label small">間隔</label>
<div class="input-group input-group-sm">
<input type="number" class="form-control" id="recurrence_interval" name="recurrence_interval" value="{{.recurring.RecurrenceInterval}}" min="1" max="12">
<span class="input-group-text" id="interval_label">{{if eq .recurring.RecurrenceType "daily"}}日{{else if eq .recurring.RecurrenceType "weekly"}}週{{else}}月{{end}}</span>
</div>
</div>
</div>
<div id="weekday_group" class="mb-3">
<label class="form-label small">曜日</label>
<div class="btn-group btn-group-sm w-100" role="group">
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd0" value="0" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 0}}checked{{end}}{{end}}>
<label class="btn btn-outline-primary" for="wd0"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd1" value="1" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 1}}checked{{end}}{{end}}>
<label class="btn btn-outline-primary" for="wd1"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd2" value="2" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 2}}checked{{end}}{{end}}>
<label class="btn btn-outline-primary" for="wd2"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd3" value="3" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 3}}checked{{end}}{{end}}>
<label class="btn btn-outline-primary" for="wd3"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd4" value="4" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 4}}checked{{end}}{{end}}>
<label class="btn btn-outline-primary" for="wd4"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd5" value="5" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 5}}checked{{end}}{{end}}>
<label class="btn btn-outline-primary" for="wd5"></label>
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd6" value="6" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 6}}checked{{end}}{{end}}>
<label class="btn btn-outline-primary" for="wd6"></label>
</div>
</div>
<div id="day_group" class="mb-3">
<label for="recurrence_day" class="form-label small"></label>
<select class="form-select form-select-sm" id="recurrence_day" name="recurrence_day">
{{range $i := seq 1 31}}
<option value="{{$i}}" {{if $.recurring.RecurrenceDay}}{{if eq (derefInt $.recurring.RecurrenceDay) $i}}selected{{end}}{{end}}>{{$i}}日</option>
{{end}}
</select>
</div>
<hr class="my-2">
<div class="d-flex justify-content-between align-items-center">
<span class="small text-muted">状態:</span>
{{if .recurring.IsActive}}
<span class="badge bg-success">有効</span>
{{else}}
<span class="badge bg-secondary">停止中</span>
{{end}}
</div>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
{{if .recurring.IsActive}}
<button type="button" class="btn btn-outline-danger" onclick="if(confirm('繰り返しを停止しますか?今後新しい課題は自動作成されなくなります。')) document.getElementById('stopForm').submit();">
<i class="bi bi-stop-fill me-1"></i>停止
</button>
{{else}}
<button type="button" class="btn btn-outline-success" onclick="document.getElementById('resumeForm').submit();">
<i class="bi bi-play-fill me-1"></i>再開
</button>
{{end}}
<button type="button" class="btn btn-outline-danger ms-auto" onclick="if(confirm('この繰り返し設定を削除しますか?この操作は取り消せません。')) document.getElementById('deleteForm').submit();">
<i class="bi bi-trash me-1"></i>削除
</button>
</div>
</form>
{{if .recurring.IsActive}}
<form id="stopForm" action="/recurring/{{.recurring.ID}}/stop" method="POST" class="d-none">
{{.csrfField}}
</form>
{{else}}
<form id="resumeForm" action="/recurring/{{.recurring.ID}}/resume" method="POST" class="d-none">
{{.csrfField}}
</form>
{{end}}
<form id="deleteForm" action="/recurring/{{.recurring.ID}}/delete" method="POST" class="d-none">
{{.csrfField}}
</form>
</div>
</div>
</div>
</div>
<script>
function updateRecurrenceOptions() {
var type = document.getElementById('recurrence_type').value;
document.getElementById('weekday_group').style.display = type === 'weekly' ? 'block' : 'none';
document.getElementById('day_group').style.display = type === 'monthly' ? 'block' : 'none';
var label = document.getElementById('interval_label');
if (type === 'daily') label.textContent = '日';
else if (type === 'weekly') label.textContent = '週';
else if (type === 'monthly') label.textContent = '月';
}
document.addEventListener('DOMContentLoaded', function() {
updateRecurrenceOptions();
});
</script>
{{end}}

View File

@@ -0,0 +1,68 @@
{{template "base" .}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center">
<h4 class="mb-0 fw-bold"><i class="bi bi-arrow-repeat me-2"></i>繰り返し設定一覧</h4>
</div>
<a href="/assignments" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>課題一覧に戻る
</a>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="ps-3">タイトル</th>
<th>科目</th>
<th>繰り返し</th>
<th>状態</th>
<th class="text-end pe-3">操作</th>
</tr>
</thead>
<tbody>
{{range .recurrings}}
<tr>
<td class="ps-3">
<div class="fw-bold">{{.Title}}</div>
{{if .Description}}
<div class="text-muted small text-truncate" style="max-width: 200px;">{{.Description}}</div>
{{end}}
</td>
<td>
{{if .Subject}}
<span class="badge bg-secondary">{{.Subject}}</span>
{{else}}
<span class="text-muted">-</span>
{{end}}
</td>
<td>
<span class="text-dark">{{recurringSummary .}}</span>
</td>
<td>
{{if .IsActive}}
<span class="badge bg-success">有効</span>
{{else}}
<span class="badge bg-secondary">停止中</span>
{{end}}
</td>
<td class="text-end pe-3">
<a href="/recurring/{{.ID}}/edit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
</td>
</tr>
{{else}}
<tr>
<td colspan="5" class="text-center py-4 text-muted">
繰り返し設定がありません
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}