initial commit
This commit is contained in:
commit
d40b69f1f9
58 changed files with 7919 additions and 0 deletions
9
.prettierrc
Normal file
9
.prettierrc
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"useTabs": true,
|
||||||
|
"printWidth": 80,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"plugins": ["prettier-plugin-organize-imports"]
|
||||||
|
}
|
||||||
5
.zed/settings.json
Normal file
5
.zed/settings.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// Folder-specific settings
|
||||||
|
//
|
||||||
|
// For a full list of overridable settings, and general information on folder-specific settings,
|
||||||
|
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
|
||||||
|
{}
|
||||||
9
Caddyfile
Normal file
9
Caddyfile
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
http://localhost:8000 {
|
||||||
|
handle /api/v1/* {
|
||||||
|
reverse_proxy http://localhost:8088
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy http://localhost:5173
|
||||||
|
}
|
||||||
|
}
|
||||||
10
README.md
Normal file
10
README.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# house
|
||||||
|
|
||||||
|
a home task management system
|
||||||
|
|
||||||
|
## tech stack
|
||||||
|
- golang
|
||||||
|
- bamboo (wip web framework in `backend/router` & `backend/middleware`)
|
||||||
|
- sqlc
|
||||||
|
- tanstack router
|
||||||
|
- radix-ui themes, icons, & primitives
|
||||||
1
backend/.gitignore
vendored
Normal file
1
backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
tmp
|
||||||
31
backend/db/db.go
Normal file
31
backend/db/db.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DBTX interface {
|
||||||
|
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||||
|
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||||
|
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||||
|
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db DBTX) *Queries {
|
||||||
|
return &Queries{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Queries struct {
|
||||||
|
db DBTX
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||||
|
return &Queries{
|
||||||
|
db: tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
45
backend/db/models.go
Normal file
45
backend/db/models.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Category struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description sql.NullString `json:"description"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
Key []byte `json:"key"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Task struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description sql.NullString `json:"description"`
|
||||||
|
Completed int64 `json:"completed"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
AssignedTo string `json:"assigned_to"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
Deadline sql.NullInt64 `json:"deadline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskCategory struct {
|
||||||
|
TaskID int64 `json:"task_id"`
|
||||||
|
CategoryID int64 `json:"category_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password []byte `json:"password"`
|
||||||
|
}
|
||||||
663
backend/db/query.sql.go
Normal file
663
backend/db/query.sql.go
Normal file
|
|
@ -0,0 +1,663 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: query.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const addTaskCategory = `-- name: AddTaskCategory :one
|
||||||
|
INSERT INTO task_categories (task_id, category_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
RETURNING task_id, category_id
|
||||||
|
`
|
||||||
|
|
||||||
|
type AddTaskCategoryParams struct {
|
||||||
|
TaskID int64 `json:"task_id"`
|
||||||
|
CategoryID int64 `json:"category_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) AddTaskCategory(ctx context.Context, arg AddTaskCategoryParams) (TaskCategory, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, addTaskCategory, arg.TaskID, arg.CategoryID)
|
||||||
|
var i TaskCategory
|
||||||
|
err := row.Scan(&i.TaskID, &i.CategoryID)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTasks = `-- name: AllTasks :many
|
||||||
|
SELECT id, name, description, completed, created_by, assigned_to, created_at, deadline FROM tasks
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) AllTasks(ctx context.Context) ([]Task, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, allTasks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Task
|
||||||
|
for rows.Next() {
|
||||||
|
var i Task
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.Completed,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.AssignedTo,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Deadline,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoriesOf = `-- name: CategoriesOf :many
|
||||||
|
SELECT c.id, c.name, c.description, c.created_by, c.created_at FROM categories c
|
||||||
|
JOIN task_categories tc ON tc.category_id = c.id
|
||||||
|
WHERE tc.task_id IN (/*SLICE:ids*/?)
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CategoriesOf(ctx context.Context, ids []int64) ([]Category, error) {
|
||||||
|
query := categoriesOf
|
||||||
|
var queryParams []interface{}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
for _, v := range ids {
|
||||||
|
queryParams = append(queryParams, v)
|
||||||
|
}
|
||||||
|
query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(ids))[1:], 1)
|
||||||
|
} else {
|
||||||
|
query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1)
|
||||||
|
}
|
||||||
|
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Category
|
||||||
|
for rows.Next() {
|
||||||
|
var i Category
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const completeTask = `-- name: CompleteTask :one
|
||||||
|
UPDATE tasks SET completed = TRUE WHERE id = ? RETURNING id, name, description, completed, created_by, assigned_to, created_at, deadline
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CompleteTask(ctx context.Context, id int64) (Task, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, completeTask, id)
|
||||||
|
var i Task
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.Completed,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.AssignedTo,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Deadline,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCategory = `-- name: CreateCategory :one
|
||||||
|
INSERT INTO categories (
|
||||||
|
name, description, created_by, created_at
|
||||||
|
) VALUES (?, ?, ?, ?)
|
||||||
|
RETURNING id, name, description, created_by, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateCategoryParams struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description sql.NullString `json:"description"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateCategory(ctx context.Context, arg CreateCategoryParams) (Category, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createCategory,
|
||||||
|
arg.Name,
|
||||||
|
arg.Description,
|
||||||
|
arg.CreatedBy,
|
||||||
|
arg.CreatedAt,
|
||||||
|
)
|
||||||
|
var i Category
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSession = `-- name: CreateSession :one
|
||||||
|
INSERT INTO sessions (
|
||||||
|
key,
|
||||||
|
user_id,
|
||||||
|
created_at
|
||||||
|
) VALUES (?, ?, ?)
|
||||||
|
RETURNING "key", user_id, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateSessionParams struct {
|
||||||
|
Key []byte `json:"key"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createSession, arg.Key, arg.UserID, arg.CreatedAt)
|
||||||
|
var i Session
|
||||||
|
err := row.Scan(&i.Key, &i.UserID, &i.CreatedAt)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTask = `-- name: CreateTask :one
|
||||||
|
INSERT INTO tasks (
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
created_by,
|
||||||
|
assigned_to,
|
||||||
|
created_at,
|
||||||
|
deadline,
|
||||||
|
completed
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, FALSE)
|
||||||
|
RETURNING id, name, description, completed, created_by, assigned_to, created_at, deadline
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateTaskParams struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description sql.NullString `json:"description"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
AssignedTo string `json:"assigned_to"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
Deadline sql.NullInt64 `json:"deadline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createTask,
|
||||||
|
arg.Name,
|
||||||
|
arg.Description,
|
||||||
|
arg.CreatedBy,
|
||||||
|
arg.AssignedTo,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.Deadline,
|
||||||
|
)
|
||||||
|
var i Task
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.Completed,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.AssignedTo,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Deadline,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const createUser = `-- name: CreateUser :one
|
||||||
|
INSERT INTO users (
|
||||||
|
id,
|
||||||
|
username
|
||||||
|
) VALUES (
|
||||||
|
?, ?
|
||||||
|
) RETURNING id, username
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateUserParams struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateUserRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createUser, arg.ID, arg.Username)
|
||||||
|
var i CreateUserRow
|
||||||
|
err := row.Scan(&i.ID, &i.Username)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCategory = `-- name: DeleteCategory :one
|
||||||
|
DELETE FROM categories WHERE id = ? LIMIT 1 RETURNING id, name, description, created_by, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteCategory(ctx context.Context, id int64) (Category, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, deleteCategory, id)
|
||||||
|
var i Category
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSession = `-- name: DeleteSession :exec
|
||||||
|
DELETE FROM sessions WHERE key = ? LIMIT 1 RETURNING "key", user_id, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteSession(ctx context.Context, key []byte) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteSession, key)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTask = `-- name: DeleteTask :exec
|
||||||
|
DELETE FROM tasks WHERE id = ? LIMIT 1 RETURNING id, name, description, completed, created_by, assigned_to, created_at, deadline
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteTask(ctx context.Context, id int64) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteTask, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSession = `-- name: GetSession :one
|
||||||
|
SELECT "key", user_id, created_at FROM sessions WHERE key = ? LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetSession(ctx context.Context, key []byte) (Session, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getSession, key)
|
||||||
|
var i Session
|
||||||
|
err := row.Scan(&i.Key, &i.UserID, &i.CreatedAt)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUser = `-- name: GetUser :one
|
||||||
|
SELECT id, username FROM users WHERE id = ? LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetUserRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetUser(ctx context.Context, id string) (GetUserRow, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getUser, id)
|
||||||
|
var i GetUserRow
|
||||||
|
err := row.Scan(&i.ID, &i.Username)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserPassword = `-- name: GetUserPassword :one
|
||||||
|
SELECT id, password FROM users WHERE username = ? LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetUserPasswordRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Password []byte `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetUserPassword(ctx context.Context, username string) (GetUserPasswordRow, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getUserPassword, username)
|
||||||
|
var i GetUserPasswordRow
|
||||||
|
err := row.Scan(&i.ID, &i.Password)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUsers = `-- name: GetUsers :many
|
||||||
|
SELECT id, username, password FROM users ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetUsers(ctx context.Context) ([]User, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getUsers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []User
|
||||||
|
for rows.Next() {
|
||||||
|
var i User
|
||||||
|
if err := rows.Scan(&i.ID, &i.Username, &i.Password); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listCategories = `-- name: ListCategories :many
|
||||||
|
SELECT id, name, description, created_by, created_at FROM categories
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListCategories(ctx context.Context) ([]Category, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listCategories)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Category
|
||||||
|
for rows.Next() {
|
||||||
|
var i Category
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskByID = `-- name: TaskByID :one
|
||||||
|
SELECT id, name, description, completed, created_by, assigned_to, created_at, deadline FROM tasks WHERE id = ? LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) TaskByID(ctx context.Context, id int64) (Task, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, taskByID, id)
|
||||||
|
var i Task
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.Completed,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.AssignedTo,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Deadline,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskCategoriesOf = `-- name: TaskCategoriesOf :many
|
||||||
|
SELECT tc.task_id, tc.category_id FROM task_categories tc
|
||||||
|
JOIN tasks t ON tc.task_id = t.id
|
||||||
|
WHERE tc.task_id IN (/*SLICE:ids*/?)
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) TaskCategoriesOf(ctx context.Context, ids []int64) ([]TaskCategory, error) {
|
||||||
|
query := taskCategoriesOf
|
||||||
|
var queryParams []interface{}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
for _, v := range ids {
|
||||||
|
queryParams = append(queryParams, v)
|
||||||
|
}
|
||||||
|
query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(ids))[1:], 1)
|
||||||
|
} else {
|
||||||
|
query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1)
|
||||||
|
}
|
||||||
|
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []TaskCategory
|
||||||
|
for rows.Next() {
|
||||||
|
var i TaskCategory
|
||||||
|
if err := rows.Scan(&i.TaskID, &i.CategoryID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasksForUser = `-- name: TasksForUser :many
|
||||||
|
SELECT id, name, description, completed, created_by, assigned_to, created_at, deadline FROM tasks WHERE assigned_to = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) TasksForUser(ctx context.Context, assignedTo string) ([]Task, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, tasksForUser, assignedTo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Task
|
||||||
|
for rows.Next() {
|
||||||
|
var i Task
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.Completed,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.AssignedTo,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Deadline,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCategory = `-- name: UpdateCategory :one
|
||||||
|
UPDATE categories SET
|
||||||
|
name = ?,
|
||||||
|
description = ?
|
||||||
|
WHERE id = ?
|
||||||
|
RETURNING id, name, description, created_by, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateCategoryParams struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description sql.NullString `json:"description"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateCategory(ctx context.Context, arg UpdateCategoryParams) (Category, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, updateCategory, arg.Name, arg.Description, arg.ID)
|
||||||
|
var i Category
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTask = `-- name: UpdateTask :one
|
||||||
|
UPDATE tasks SET
|
||||||
|
name = ?,
|
||||||
|
description = ?
|
||||||
|
WHERE id = ?
|
||||||
|
RETURNING id, name, description, completed, created_by, assigned_to, created_at, deadline
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateTaskParams struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description sql.NullString `json:"description"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateTask(ctx context.Context, arg UpdateTaskParams) (Task, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, updateTask, arg.Name, arg.Description, arg.ID)
|
||||||
|
var i Task
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.Completed,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.AssignedTo,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Deadline,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateUser = `-- name: UpdateUser :one
|
||||||
|
UPDATE users
|
||||||
|
set username = ?
|
||||||
|
WHERE id = ?
|
||||||
|
RETURNING id, username
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateUserParams struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateUserRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, updateUser, arg.Username, arg.ID)
|
||||||
|
var i UpdateUserRow
|
||||||
|
err := row.Scan(&i.ID, &i.Username)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateUserPassword = `-- name: UpdateUserPassword :one
|
||||||
|
UPDATE users
|
||||||
|
set password = ?
|
||||||
|
WHERE id = ?
|
||||||
|
RETURNING id, username
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateUserPasswordParams struct {
|
||||||
|
Password []byte `json:"password"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateUserPasswordRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) (UpdateUserPasswordRow, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, updateUserPassword, arg.Password, arg.ID)
|
||||||
|
var i UpdateUserPasswordRow
|
||||||
|
err := row.Scan(&i.ID, &i.Username)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCategories = `-- name: UserCategories :many
|
||||||
|
SELECT id, name, description, created_by, created_at FROM categories WHERE created_by = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) UserCategories(ctx context.Context, createdBy string) ([]Category, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, userCategories, createdBy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Category
|
||||||
|
for rows.Next() {
|
||||||
|
var i Category
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const userTasks = `-- name: UserTasks :many
|
||||||
|
SELECT id, name, description, completed, created_by, assigned_to, created_at, deadline FROM tasks WHERE created_by = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) UserTasks(ctx context.Context, createdBy string) ([]Task, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, userTasks, createdBy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Task
|
||||||
|
for rows.Next() {
|
||||||
|
var i Task
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.Completed,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.AssignedTo,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.Deadline,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
40
backend/dbutil/null.go
Normal file
40
backend/dbutil/null.go
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
package dbutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SerializeMaybeString(str *string) sql.NullString {
|
||||||
|
if str == nil {
|
||||||
|
return sql.NullString{
|
||||||
|
String: "",
|
||||||
|
Valid: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql.NullString{
|
||||||
|
String: *str,
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeserializeMaybeString(str sql.NullString) *string {
|
||||||
|
if !str.Valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &str.String
|
||||||
|
}
|
||||||
|
|
||||||
|
func SerializeMaybeInt(i *int64) sql.NullInt64 {
|
||||||
|
if i == nil {
|
||||||
|
return sql.NullInt64{
|
||||||
|
Int64: 0,
|
||||||
|
Valid: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql.NullInt64{
|
||||||
|
Int64: int64(*i),
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/log.go
Normal file
37
backend/log.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/lipgloss-catppuccin"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
palette = catppuccin.Mocha
|
||||||
|
styles *log.Styles
|
||||||
|
)
|
||||||
|
|
||||||
|
func levelStyle(level log.Level, color lipgloss.Color) lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
SetString(strings.TrimSpace(strings.ToUpper(level.String()))).
|
||||||
|
MaxWidth(6).
|
||||||
|
Align(lipgloss.Center).
|
||||||
|
Padding(0, 1).
|
||||||
|
Foreground(palette.Base).
|
||||||
|
Background(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
styles = log.DefaultStyles()
|
||||||
|
|
||||||
|
styles.Levels = map[log.Level]lipgloss.Style{
|
||||||
|
log.DebugLevel: levelStyle(log.DebugLevel, palette.Teal),
|
||||||
|
log.InfoLevel: levelStyle(log.InfoLevel, palette.Sapphire),
|
||||||
|
log.WarnLevel: levelStyle(log.WarnLevel, palette.Yellow),
|
||||||
|
log.ErrorLevel: levelStyle(log.ErrorLevel, palette.Red),
|
||||||
|
log.FatalLevel: levelStyle(log.FatalLevel, palette.Lavender),
|
||||||
|
}
|
||||||
|
}
|
||||||
56
backend/main.go
Normal file
56
backend/main.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
_ "embed"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/router"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/routes"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var port uint
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
flag.UintVar(&port, "port", 8088, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed schema.sql
|
||||||
|
var ddl string
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
logger := log.Default()
|
||||||
|
logger.SetReportCaller(true)
|
||||||
|
logger.SetStyles(styles)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite3", "file::memory:?cache=shared")
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
db.SetConnMaxLifetime(0)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("unable to open db", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, ddl); err != nil {
|
||||||
|
logger.Fatal("unable to migrate db", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seedDevData(db)
|
||||||
|
|
||||||
|
r := router.NewRouter(logger, db)
|
||||||
|
r.SetPrefix("/api/v1")
|
||||||
|
routes.Register(r)
|
||||||
|
|
||||||
|
logger.Info("starting server", "port", port)
|
||||||
|
if err := http.ListenAndServe(fmt.Sprintf(":%d", port), r); err != nil {
|
||||||
|
logger.Fatal("unable start http server", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
56
backend/middleware/authorize.go
Normal file
56
backend/middleware/authorize.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/db"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Authenticated(ctx *router.Context) error {
|
||||||
|
sessionCookie, err := ctx.Cookie("session")
|
||||||
|
if err != nil {
|
||||||
|
return ctx.Error(err, http.StatusUnauthorized, "login required")
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionBs, err := base64.StdEncoding.DecodeString(sessionCookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.Error(err, http.StatusUnauthorized, "login required")
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedKey := sha256.Sum256(sessionBs)
|
||||||
|
|
||||||
|
session, err := ctx.Query.GetSession(ctx, hashedKey[:])
|
||||||
|
if err != nil {
|
||||||
|
return ctx.Error(err, http.StatusUnauthorized, "login required")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := ctx.Query.GetUser(ctx, session.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.Error(err, http.StatusUnauthorized, "login required")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.With(sessionKey, session)
|
||||||
|
ctx.With(userKey, user)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Session(route router.AuthorizedRoute, ctx *router.Context) db.Session {
|
||||||
|
user, ok := ctx.Value(sessionKey).(db.Session)
|
||||||
|
if !ok {
|
||||||
|
panic(errors.New("middleware.Session cannot be used from an unauthenticated route"))
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func User(route router.AuthorizedRoute, ctx *router.Context) db.GetUserRow {
|
||||||
|
user, ok := ctx.Value(userKey).(db.GetUserRow)
|
||||||
|
if !ok {
|
||||||
|
panic(errors.New("middleware.User cannot be used from an unauthenticated route"))
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
10
backend/middleware/context.go
Normal file
10
backend/middleware/context.go
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
type middlewareContextKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionKey middlewareContextKey = iota
|
||||||
|
userKey
|
||||||
|
jsonBodyKey
|
||||||
|
paramsKey
|
||||||
|
)
|
||||||
49
backend/middleware/json.go
Normal file
49
backend/middleware/json.go
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParseJSONBody[T any](ctx *router.Context) error {
|
||||||
|
contentType := ctx.Request.Header.Get("Content-Type")
|
||||||
|
if contentType != "application/json" {
|
||||||
|
return ctx.GenericError(nil, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body T
|
||||||
|
|
||||||
|
dec := json.NewDecoder(ctx.Request.Body)
|
||||||
|
err := dec.Decode(&body)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.With(jsonBodyKey, body)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseJSONBodyWithValidator[T any](
|
||||||
|
ctx *router.Context,
|
||||||
|
r router.ValidatedBodyRoute,
|
||||||
|
validator func(value T) error,
|
||||||
|
) error {
|
||||||
|
err := ParseJSONBody[T](ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := JSONBody[T](r, ctx)
|
||||||
|
return validator(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func JSONBody[T any](r router.ValidatedBodyRoute, ctx *router.Context) T {
|
||||||
|
body, ok := ctx.Value(jsonBodyKey).(T)
|
||||||
|
if !ok {
|
||||||
|
panic(errors.New("middleware.JSONBody cannot be used with a route that doesn't implement router.ValidatedBodyRoute"))
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
33
backend/middleware/param.go
Normal file
33
backend/middleware/param.go
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/router"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidateUUIDParam(ctx *router.Context, key string) error {
|
||||||
|
param := ctx.Parameter(key)
|
||||||
|
if err := uuid.Validate(param); err != nil {
|
||||||
|
return ctx.Error(err, 400, "Bad path parameter "+key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateIDParam(ctx *router.Context, key string) error {
|
||||||
|
param := ctx.Parameter(key)
|
||||||
|
v, err := strconv.ParseInt(param, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.Error(err, 400, "Bad path parameter "+key)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.With(paramsKey, v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: expand this kinda api for more than just one param, better validation, etc.
|
||||||
|
func ParamID(r router.ValidatedParamRoute, ctx *router.Context) int64 {
|
||||||
|
return ctx.Value(paramsKey).(int64)
|
||||||
|
}
|
||||||
117
backend/query.sql
Normal file
117
backend/query.sql
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
-- name: GetUser :one
|
||||||
|
SELECT id, username FROM users WHERE id = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetUsers :many
|
||||||
|
SELECT * FROM users ORDER BY name;
|
||||||
|
|
||||||
|
-- name: GetUserPassword :one
|
||||||
|
SELECT id, password FROM users WHERE username = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: CreateUser :one
|
||||||
|
INSERT INTO users (
|
||||||
|
id,
|
||||||
|
username
|
||||||
|
) VALUES (
|
||||||
|
?, ?
|
||||||
|
) RETURNING id, username;
|
||||||
|
|
||||||
|
-- name: UpdateUser :one
|
||||||
|
UPDATE users
|
||||||
|
set username = ?
|
||||||
|
WHERE id = ?
|
||||||
|
RETURNING id, username;
|
||||||
|
|
||||||
|
-- name: UpdateUserPassword :one
|
||||||
|
UPDATE users
|
||||||
|
set password = ?
|
||||||
|
WHERE id = ?
|
||||||
|
RETURNING id, username;
|
||||||
|
|
||||||
|
-- name: GetSession :one
|
||||||
|
SELECT * FROM sessions WHERE key = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: CreateSession :one
|
||||||
|
INSERT INTO sessions (
|
||||||
|
key,
|
||||||
|
user_id,
|
||||||
|
created_at
|
||||||
|
) VALUES (?, ?, ?)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteSession :exec
|
||||||
|
DELETE FROM sessions WHERE key = ? LIMIT 1 RETURNING *;
|
||||||
|
|
||||||
|
-- name: TaskByID :one
|
||||||
|
SELECT * FROM tasks WHERE id = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: AllTasks :many
|
||||||
|
SELECT * FROM tasks;
|
||||||
|
|
||||||
|
-- name: TasksForUser :many
|
||||||
|
SELECT * FROM tasks WHERE assigned_to = ?;
|
||||||
|
|
||||||
|
-- name: UserTasks :many
|
||||||
|
SELECT * FROM tasks WHERE created_by = ?;
|
||||||
|
|
||||||
|
|
||||||
|
-- name: CategoriesOf :many
|
||||||
|
SELECT c.* FROM categories c
|
||||||
|
JOIN task_categories tc ON tc.category_id = c.id
|
||||||
|
WHERE tc.task_id IN (sqlc.slice('ids'));
|
||||||
|
|
||||||
|
-- name: TaskCategoriesOf :many
|
||||||
|
SELECT tc.* FROM task_categories tc
|
||||||
|
JOIN tasks t ON tc.task_id = t.id
|
||||||
|
WHERE tc.task_id IN (sqlc.slice('ids'));
|
||||||
|
|
||||||
|
-- name: AddTaskCategory :one
|
||||||
|
INSERT INTO task_categories (task_id, category_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateTask :one
|
||||||
|
INSERT INTO tasks (
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
created_by,
|
||||||
|
assigned_to,
|
||||||
|
created_at,
|
||||||
|
deadline,
|
||||||
|
completed
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, FALSE)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CompleteTask :one
|
||||||
|
UPDATE tasks SET completed = TRUE WHERE id = ? RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateTask :one
|
||||||
|
UPDATE tasks SET
|
||||||
|
name = ?,
|
||||||
|
description = ?
|
||||||
|
WHERE id = ?
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteTask :exec
|
||||||
|
DELETE FROM tasks WHERE id = ? LIMIT 1 RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateCategory :one
|
||||||
|
INSERT INTO categories (
|
||||||
|
name, description, created_by, created_at
|
||||||
|
) VALUES (?, ?, ?, ?)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListCategories :many
|
||||||
|
SELECT * FROM categories;
|
||||||
|
|
||||||
|
-- name: UpdateCategory :one
|
||||||
|
UPDATE categories SET
|
||||||
|
name = ?,
|
||||||
|
description = ?
|
||||||
|
WHERE id = ?
|
||||||
|
RETURNING *;
|
||||||
|
-- name: DeleteCategory :one
|
||||||
|
DELETE FROM categories WHERE id = ? LIMIT 1 RETURNING *;
|
||||||
|
|
||||||
|
-- name: UserCategories :many
|
||||||
|
SELECT * FROM categories WHERE created_by = ?;
|
||||||
|
|
||||||
204
backend/router/route.go
Normal file
204
backend/router/route.go
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/db"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IRequestContext interface {
|
||||||
|
Search(key string) string
|
||||||
|
Parameter(key string) string
|
||||||
|
Cookie(name string) (*http.Cookie, error)
|
||||||
|
SetCookie(cookie *http.Cookie)
|
||||||
|
JSON(statusCode int, body any) error
|
||||||
|
Redirect(statusCode int, url string) error
|
||||||
|
Text(statusCode int, body string) error
|
||||||
|
Bytes(statusCode int, body []byte) error
|
||||||
|
With(key any, value any)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestError struct {
|
||||||
|
Inner error `json:"-"`
|
||||||
|
RequestID string `json:"requestID"`
|
||||||
|
StatusCode int `json:"statusCode"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error implements error.
|
||||||
|
func (r *RequestError) Error() string {
|
||||||
|
return r.Inner.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ error = new(RequestError)
|
||||||
|
|
||||||
|
type Context struct {
|
||||||
|
id string
|
||||||
|
statusCode int
|
||||||
|
start time.Time
|
||||||
|
log *log.Logger
|
||||||
|
context context.Context
|
||||||
|
search url.Values
|
||||||
|
|
||||||
|
resp http.ResponseWriter
|
||||||
|
Request *http.Request
|
||||||
|
DB *sql.Conn
|
||||||
|
Query *db.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect implements IRequestContext.
|
||||||
|
func (r *Context) Redirect(statusCode int, url string) error {
|
||||||
|
http.Redirect(r, r.Request, url, statusCode)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// With implements IRequestContext.
|
||||||
|
func (r *Context) With(key, value any) {
|
||||||
|
r.context = context.WithValue(r.context, key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes implements IRequestContext.
|
||||||
|
func (r *Context) Bytes(statusCode int, body []byte) error {
|
||||||
|
r.WriteHeader(statusCode)
|
||||||
|
_, err := r.Write(body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text implements IRequestContext.
|
||||||
|
func (r *Context) Text(statusCode int, body string) error {
|
||||||
|
r.Header().Set("Content-Type", "text/plain")
|
||||||
|
return r.Bytes(statusCode, []byte(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON implements IRequestContext.
|
||||||
|
func (r *Context) JSON(statusCode int, body any) error {
|
||||||
|
enc := json.NewEncoder(r)
|
||||||
|
r.Header().Set("Content-Type", "application/json")
|
||||||
|
r.WriteHeader(statusCode)
|
||||||
|
return enc.Encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Context) Error(err error, statusCode int, message string) error {
|
||||||
|
return &RequestError{
|
||||||
|
Inner: err,
|
||||||
|
RequestID: r.id,
|
||||||
|
StatusCode: statusCode,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (r *Context) GenericError(err error, statusCode int) error {
|
||||||
|
message := "Error"
|
||||||
|
|
||||||
|
switch statusCode {
|
||||||
|
case http.StatusBadRequest:
|
||||||
|
message = "Bad request"
|
||||||
|
case http.StatusUnauthorized:
|
||||||
|
message = "Unauthorized"
|
||||||
|
case http.StatusNotFound:
|
||||||
|
message = "Not found"
|
||||||
|
case http.StatusInternalServerError:
|
||||||
|
message = "Internal server error"
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.Error(err, statusCode, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie implements IRequestContext.
|
||||||
|
func (r *Context) Cookie(name string) (*http.Cookie, error) {
|
||||||
|
return r.Request.Cookie(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameter implements IRequestContext.
|
||||||
|
func (r *Context) Parameter(key string) string {
|
||||||
|
return r.Request.PathValue(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search implements IRequestContext.
|
||||||
|
func (r *Context) Search(key string) string {
|
||||||
|
if r.search == nil {
|
||||||
|
r.search = r.Request.URL.Query()
|
||||||
|
}
|
||||||
|
return r.search.Get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCookie implements IRequestContext.
|
||||||
|
func (r *Context) SetCookie(cookie *http.Cookie) {
|
||||||
|
http.SetCookie(r, cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deadline implements context.Context.
|
||||||
|
func (r *Context) Deadline() (deadline time.Time, ok bool) {
|
||||||
|
return r.context.Deadline()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done implements context.Context.
|
||||||
|
func (r *Context) Done() <-chan struct{} {
|
||||||
|
return r.context.Done()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Err implements context.Context.
|
||||||
|
func (r *Context) Err() error {
|
||||||
|
return r.context.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements context.Context.
|
||||||
|
func (r *Context) Value(key any) any {
|
||||||
|
return r.context.Value(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header implements http.ResponseWriter.
|
||||||
|
func (r *Context) Header() http.Header {
|
||||||
|
return r.resp.Header()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write implements http.ResponseWriter.
|
||||||
|
func (r *Context) Write(bs []byte) (int, error) {
|
||||||
|
if r.statusCode == 0 {
|
||||||
|
r.statusCode = 200
|
||||||
|
}
|
||||||
|
|
||||||
|
r.log.Helper()
|
||||||
|
r.log.Info("",
|
||||||
|
"id", r.id,
|
||||||
|
"duration", time.Since(r.start),
|
||||||
|
"status", r.statusCode,
|
||||||
|
)
|
||||||
|
|
||||||
|
return r.resp.Write(bs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteHeader implements http.ResponseWriter.
|
||||||
|
func (r *Context) WriteHeader(statusCode int) {
|
||||||
|
r.statusCode = statusCode
|
||||||
|
r.resp.WriteHeader(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ context.Context = new(Context)
|
||||||
|
var _ http.ResponseWriter = new(Context)
|
||||||
|
var _ IRequestContext = new(Context)
|
||||||
|
|
||||||
|
type Route interface {
|
||||||
|
Handle(ctx *Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthorizedRoute interface {
|
||||||
|
Authorize(ctx *Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidatedSearchRoute interface {
|
||||||
|
ValidateSearch(ctx *Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidatedBodyRoute interface {
|
||||||
|
ValidateBody(ctx *Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidatedParamRoute interface {
|
||||||
|
ValidateParams(ctx *Context) error
|
||||||
|
}
|
||||||
203
backend/router/router.go
Normal file
203
backend/router/router.go
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/db"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IRouter interface {
|
||||||
|
Register(method, pattern string, route Route)
|
||||||
|
GET(pattern string, route Route)
|
||||||
|
POST(pattern string, route Route)
|
||||||
|
HEAD(pattern string, route Route)
|
||||||
|
PUT(pattern string, route Route)
|
||||||
|
PATCH(pattern string, route Route)
|
||||||
|
DELETE(pattern string, route Route)
|
||||||
|
OPTIONS(pattern string, route Route)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Router struct {
|
||||||
|
log *log.Logger
|
||||||
|
mux *http.ServeMux
|
||||||
|
db *sql.DB
|
||||||
|
prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(logger *log.Logger, db *sql.DB) *Router {
|
||||||
|
r := new(Router)
|
||||||
|
r.mux = http.NewServeMux()
|
||||||
|
r.log = logger
|
||||||
|
r.db = db
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (router *Router) SetPrefix(prefix string) {
|
||||||
|
router.prefix = prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
func (router *Router) NewContext(w http.ResponseWriter, r *http.Request) (*Context, error) {
|
||||||
|
router.log.Helper()
|
||||||
|
|
||||||
|
ctx := new(Context)
|
||||||
|
id, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := router.db.Conn(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
method := r.Method
|
||||||
|
if method == "" {
|
||||||
|
method = "GET"
|
||||||
|
}
|
||||||
|
path := r.URL.EscapedPath()
|
||||||
|
|
||||||
|
ctx.id = id.String()
|
||||||
|
ctx.start = time.Now()
|
||||||
|
ctx.resp = w
|
||||||
|
ctx.Request = r
|
||||||
|
ctx.log = router.log.WithPrefix(method + " " + path)
|
||||||
|
ctx.DB = conn
|
||||||
|
ctx.Query = db.New(ctx.DB)
|
||||||
|
ctx.context = r.Context()
|
||||||
|
|
||||||
|
return ctx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (router *Router) handleError(ctx *Context, err error) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if reqErr, ok := err.(*RequestError); ok {
|
||||||
|
err = ctx.JSON(reqErr.StatusCode, reqErr)
|
||||||
|
ctx.log.Warn("error during request", "err", reqErr.Inner)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All implements IRouter.
|
||||||
|
func (router *Router) Register(method, path string, route Route) {
|
||||||
|
router.log.Helper()
|
||||||
|
|
||||||
|
routeImpl := any(route)
|
||||||
|
|
||||||
|
authorize, implAuthorized := routeImpl.(AuthorizedRoute)
|
||||||
|
validateBody, implBodyValidation := routeImpl.(ValidatedBodyRoute)
|
||||||
|
validateParams, implParamValidation := routeImpl.(ValidatedParamRoute)
|
||||||
|
validateSearch, implSearchValidation := routeImpl.(ValidatedSearchRoute)
|
||||||
|
|
||||||
|
pattern := fmt.Sprintf("%s %s%s", method, router.prefix, path)
|
||||||
|
|
||||||
|
router.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, err := router.NewContext(w, r)
|
||||||
|
//TODO: don't panic :3
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if implAuthorized {
|
||||||
|
err = authorize.Authorize(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
router.handleError(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if implSearchValidation {
|
||||||
|
err = validateSearch.ValidateSearch(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
router.handleError(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if implParamValidation {
|
||||||
|
err = validateParams.ValidateParams(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
router.handleError(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if implBodyValidation {
|
||||||
|
err = validateBody.ValidateBody(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
router.handleError(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = route.Handle(ctx)
|
||||||
|
router.handleError(ctx, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET implements IRouter.
|
||||||
|
func (router *Router) GET(pattern string, route Route) {
|
||||||
|
router.Register("GET ", pattern, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE implements IRouter.
|
||||||
|
func (router *Router) DELETE(pattern string, route Route) {
|
||||||
|
router.Register("DELETE", pattern, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEAD implements IRouter.
|
||||||
|
func (router *Router) HEAD(pattern string, route Route) {
|
||||||
|
router.Register("HEAD", pattern, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPTIONS implements IRouter.
|
||||||
|
func (router *Router) OPTIONS(pattern string, route Route) {
|
||||||
|
router.Register("OPTIONS", pattern, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST implements IRouter.
|
||||||
|
func (router *Router) POST(pattern string, route Route) {
|
||||||
|
router.Register("POST", pattern, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT implements IRouter.
|
||||||
|
func (router *Router) PUT(pattern string, route Route) {
|
||||||
|
router.Register("PUT", pattern, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH implements IRouter.
|
||||||
|
func (router *Router) PATCH(pattern string, route Route) {
|
||||||
|
router.Register("PATCH", pattern, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements http.Handler.
|
||||||
|
func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
router.log.Helper()
|
||||||
|
router.log.Warn("recovered from panic", "err", rec)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
router.mux.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ http.Handler = new(Router)
|
||||||
|
var _ IRouter = new(Router)
|
||||||
37
backend/routes/categories.go
Normal file
37
backend/routes/categories.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/db"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/middleware"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
routes["GET"]["/category"] = new(CategoriesGET)
|
||||||
|
}
|
||||||
|
|
||||||
|
type categoriesResponse struct {
|
||||||
|
Categories []db.Category `json:"categories"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoriesGET struct{}
|
||||||
|
|
||||||
|
// Authorize implements router.AuthorizedRoute.
|
||||||
|
func (c *CategoriesGET) Authorize(ctx *router.Context) error {
|
||||||
|
return middleware.Authenticated(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle implements router.Route.
|
||||||
|
func (c *CategoriesGET) Handle(ctx *router.Context) error {
|
||||||
|
categories, err := ctx.Query.ListCategories(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return ctx.JSON(200, categoriesResponse{
|
||||||
|
Categories: categories,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ router.AuthorizedRoute = new(CategoriesGET)
|
||||||
76
backend/routes/category-create.go
Normal file
76
backend/routes/category-create.go
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/db"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/dbutil"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/middleware"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
routes["POST"]["/category"] = new(CategoryCreate)
|
||||||
|
}
|
||||||
|
|
||||||
|
type categoryCreateBody struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type categoryCreateResponse struct {
|
||||||
|
Category db.Category `json:"category"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryCreate struct{}
|
||||||
|
|
||||||
|
// ValidateBody implements router.ValidatedBodyRoute.
|
||||||
|
func (c *CategoryCreate) ValidateBody(ctx *router.Context) error {
|
||||||
|
return middleware.ParseJSONBodyWithValidator(
|
||||||
|
ctx, c, func(value categoryCreateBody) error {
|
||||||
|
if len(value.Name) < 5 {
|
||||||
|
return ctx.Error(nil, 400, "Name too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.Description != nil {
|
||||||
|
description := *value.Description
|
||||||
|
|
||||||
|
if len(description) > 100 {
|
||||||
|
return ctx.Error(nil, 400, "Description too long")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize implements router.AuthorizedRoute.
|
||||||
|
func (c *CategoryCreate) Authorize(ctx *router.Context) error {
|
||||||
|
return middleware.Authenticated(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle implements router.Route.
|
||||||
|
func (c *CategoryCreate) Handle(ctx *router.Context) error {
|
||||||
|
user := middleware.User(c, ctx)
|
||||||
|
body := middleware.JSONBody[categoryCreateBody](c, ctx)
|
||||||
|
|
||||||
|
category, err := ctx.Query.CreateCategory(ctx, db.CreateCategoryParams{
|
||||||
|
Name: body.Name,
|
||||||
|
Description: dbutil.SerializeMaybeString(body.Description),
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.JSON(200, categoryCreateResponse{
|
||||||
|
Category: category,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ router.AuthorizedRoute = new(CategoryCreate)
|
||||||
|
var _ router.ValidatedBodyRoute = new(CategoryCreate)
|
||||||
86
backend/routes/login.go
Normal file
86
backend/routes/login.go
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/db"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/middleware"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/router"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
routes["POST"]["/login"] = new(Login)
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginBody struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginResponse struct {
|
||||||
|
User db.GetUserRow `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Login struct{}
|
||||||
|
|
||||||
|
// ValidateBody implements router.ValidatedBodyRoute.
|
||||||
|
func (l *Login) ValidateBody(ctx *router.Context) error {
|
||||||
|
return middleware.ParseJSONBody[loginBody](ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle implements router.Route.
|
||||||
|
func (l *Login) Handle(ctx *router.Context) error {
|
||||||
|
body := middleware.JSONBody[loginBody](l, ctx)
|
||||||
|
|
||||||
|
user, err := ctx.Query.GetUserPassword(ctx, body.Username)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.Error(err, http.StatusBadRequest, "Invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = bcrypt.CompareHashAndPassword(user.Password, []byte(body.Password))
|
||||||
|
if err != nil {
|
||||||
|
return ctx.Error(err, http.StatusBadRequest, "Invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenBs := make([]byte, 32)
|
||||||
|
_, err = rand.Read(tokenBs)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
encodedToken := base64.StdEncoding.EncodeToString(tokenBs)
|
||||||
|
hashedToken := sha256.Sum256(tokenBs)
|
||||||
|
|
||||||
|
_, err = ctx.Query.CreateSession(ctx, db.CreateSessionParams{
|
||||||
|
Key: hashedToken[:],
|
||||||
|
UserID: user.ID,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
loggedInUser, err := ctx.Query.GetUser(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.SetCookie(&http.Cookie{
|
||||||
|
Name: "session",
|
||||||
|
Value: encodedToken,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return ctx.JSON(200, loginResponse{
|
||||||
|
User: loggedInUser,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ router.ValidatedBodyRoute = new(Login)
|
||||||
22
backend/routes/routes.go
Normal file
22
backend/routes/routes.go
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
var routes = map[string]map[string]router.Route{
|
||||||
|
"GET": map[string]router.Route{},
|
||||||
|
"POST": map[string]router.Route{},
|
||||||
|
"PATCH": map[string]router.Route{},
|
||||||
|
"DELETE": map[string]router.Route{},
|
||||||
|
"PUT": map[string]router.Route{},
|
||||||
|
"OPTIONS": map[string]router.Route{},
|
||||||
|
}
|
||||||
|
|
||||||
|
func Register(r router.IRouter) {
|
||||||
|
for method, patterns := range routes {
|
||||||
|
for pattern, route := range patterns {
|
||||||
|
r.Register(method, pattern, route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
backend/routes/task-add-categories.go
Normal file
89
backend/routes/task-add-categories.go
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
// "net/http"
|
||||||
|
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/db"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/middleware"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
routes["POST"]["/task/{id}/categories"] = new(TaskAddCategories)
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskAddCategoriesBody struct {
|
||||||
|
TaskID int64 `json:"task_id"`
|
||||||
|
CategoryIDs []int64 `json:"category_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskAddCategoriesResponse struct {
|
||||||
|
TaskCategories []db.TaskCategory `json:"task_categories"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskAddCategories struct{}
|
||||||
|
|
||||||
|
// ValidateBody implements router.ValidatedBodyRoute.
|
||||||
|
func (t *TaskAddCategories) ValidateBody(ctx *router.Context) error {
|
||||||
|
return middleware.ParseJSONBody[taskAddCategoriesBody](ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateParams implements router.ValidatedParamRoute.
|
||||||
|
func (t *TaskAddCategories) ValidateParams(ctx *router.Context) error {
|
||||||
|
return middleware.ValidateUUIDParam(ctx, "id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize implements router.AuthorizedRoute.
|
||||||
|
func (t *TaskAddCategories) Authorize(ctx *router.Context) error {
|
||||||
|
return middleware.Authenticated(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle implements router.Route.
|
||||||
|
func (t *TaskAddCategories) Handle(ctx *router.Context) error {
|
||||||
|
user := middleware.User(t, ctx)
|
||||||
|
body := middleware.JSONBody[taskAddCategoriesBody](t, ctx)
|
||||||
|
|
||||||
|
tx, err := ctx.DB.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := ctx.Query.WithTx(tx)
|
||||||
|
|
||||||
|
task, err := query.TaskByID(ctx, body.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return ctx.GenericError(err, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.ID != task.CreatedBy && user.ID != task.AssignedTo {
|
||||||
|
return ctx.GenericError(err, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
taskCategories := make([]db.TaskCategory, len(body.CategoryIDs))
|
||||||
|
|
||||||
|
for _, categoryID := range body.CategoryIDs {
|
||||||
|
tc, err := query.AddTaskCategory(ctx, db.AddTaskCategoryParams{
|
||||||
|
TaskID: task.ID,
|
||||||
|
CategoryID: categoryID,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return ctx.Error(err, http.StatusNotFound, fmt.Sprintf("Category ID %d not found", categoryID))
|
||||||
|
}
|
||||||
|
|
||||||
|
taskCategories = append(taskCategories, tc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.JSON(200, taskAddCategoriesResponse{
|
||||||
|
TaskCategories: taskCategories,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ router.AuthorizedRoute = new(TaskAddCategories)
|
||||||
|
var _ router.ValidatedBodyRoute = new(TaskAddCategories)
|
||||||
|
var _ router.ValidatedParamRoute = new(TaskAddCategories)
|
||||||
57
backend/routes/task-complete.go
Normal file
57
backend/routes/task-complete.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/db"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/middleware"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
routes["POST"]["/task/{id}/complete"] = new(TaskComplete)
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskCompleteResponse struct {
|
||||||
|
Task db.Task `json:"task"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskComplete struct{}
|
||||||
|
|
||||||
|
// ValidateParams implements router.ValidatedParamRoute.
|
||||||
|
func (t *TaskComplete) ValidateParams(ctx *router.Context) error {
|
||||||
|
return middleware.ValidateIDParam(ctx, "id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize implements router.AuthorizedRoute.
|
||||||
|
func (t *TaskComplete) Authorize(ctx *router.Context) error {
|
||||||
|
return middleware.Authenticated(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle implements router.Route.
|
||||||
|
func (t *TaskComplete) Handle(ctx *router.Context) error {
|
||||||
|
id := middleware.ParamID(t, ctx)
|
||||||
|
|
||||||
|
tx, err := ctx.DB.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := ctx.Query.WithTx(tx)
|
||||||
|
|
||||||
|
task, err := query.CompleteTask(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return ctx.JSON(200, taskCompleteResponse{
|
||||||
|
Task: task,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ router.AuthorizedRoute = new(TaskComplete)
|
||||||
|
var _ router.ValidatedParamRoute = new(TaskComplete)
|
||||||
93
backend/routes/task-create.go
Normal file
93
backend/routes/task-create.go
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/db"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/dbutil"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/middleware"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
routes["POST"]["/task"] = new(TaskCreate)
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskCreateBody struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
Deadline *int64 `json:"deadline,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskCreateResponse struct {
|
||||||
|
Task db.Task `json:"task"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskCreate struct{}
|
||||||
|
|
||||||
|
// Authorize implements router.AuthorizedRoute.
|
||||||
|
func (t *TaskCreate) Authorize(ctx *router.Context) error {
|
||||||
|
return middleware.Authenticated(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateBody implements router.ValidatedBodyRoute.
|
||||||
|
func (t *TaskCreate) ValidateBody(ctx *router.Context) error {
|
||||||
|
return middleware.ParseJSONBodyWithValidator[taskCreateBody](
|
||||||
|
ctx, t, func(value taskCreateBody) error {
|
||||||
|
if len(value.Name) > 64 {
|
||||||
|
return ctx.Error(nil, 400, "name too long")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(value.Name) < 3 {
|
||||||
|
return ctx.Error(nil, 400, "name too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.Description != nil {
|
||||||
|
description := *value.Description
|
||||||
|
|
||||||
|
if len(description) > 200 {
|
||||||
|
return ctx.Error(nil, 400, "description too long")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.Deadline != nil {
|
||||||
|
deadline := *value.Deadline
|
||||||
|
|
||||||
|
if deadline < 0 {
|
||||||
|
return ctx.Error(nil, 400, "deadline cannot be below 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
if deadline < time.Now().Unix() {
|
||||||
|
return ctx.Error(nil, 400, "deadline cannot be in the past")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle implements router.Route.
|
||||||
|
func (t *TaskCreate) Handle(ctx *router.Context) error {
|
||||||
|
user := middleware.User(t, ctx)
|
||||||
|
body := middleware.JSONBody[taskCreateBody](t, ctx)
|
||||||
|
|
||||||
|
task, err := ctx.Query.CreateTask(ctx, db.CreateTaskParams{
|
||||||
|
Name: body.Name,
|
||||||
|
Description: dbutil.SerializeMaybeString(body.Description),
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
AssignedTo: user.ID,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
Deadline: dbutil.SerializeMaybeInt(body.Deadline),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.JSON(200, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ router.AuthorizedRoute = new(TaskCreate)
|
||||||
|
var _ router.ValidatedBodyRoute = new(TaskCreate)
|
||||||
57
backend/routes/task-get.go
Normal file
57
backend/routes/task-get.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/db"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/middleware"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
routes["GET"]["/task"] = new(TasksGET)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tasksResponse struct {
|
||||||
|
Tasks []db.Task `json:"tasks"`
|
||||||
|
Categories []db.Category `json:"categories"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TasksGET struct{}
|
||||||
|
|
||||||
|
// Authorize implements router.AuthorizedRoute.
|
||||||
|
func (t *TasksGET) Authorize(ctx *router.Context) error {
|
||||||
|
return middleware.Authenticated(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle implements router.Route.
|
||||||
|
func (t *TasksGET) Handle(ctx *router.Context) error {
|
||||||
|
tx, err := ctx.DB.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := ctx.Query.WithTx(tx)
|
||||||
|
|
||||||
|
tasks, err := query.AllTasks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
taskIDs := make([]int64, len(tasks))
|
||||||
|
for i, t := range tasks {
|
||||||
|
taskIDs[i] = t.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
categories, err := query.CategoriesOf(ctx, taskIDs)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.JSON(200, tasksResponse{
|
||||||
|
Tasks: tasks,
|
||||||
|
Categories: categories,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ router.AuthorizedRoute = new(TasksGET)
|
||||||
47
backend/routes/user-get.go
Normal file
47
backend/routes/user-get.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/middleware"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
routes["GET"]["/user/{id}"] = new(UserGET)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserGET struct{}
|
||||||
|
|
||||||
|
// ValidateParams implements router.ValidatedParamRoute.
|
||||||
|
func (u *UserGET) ValidateParams(ctx *router.Context) error {
|
||||||
|
if ctx.Parameter("id") == "me" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return middleware.ValidateUUIDParam(ctx, "id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize implements router.AuthorizedRoute.
|
||||||
|
func (u *UserGET) Authorize(ctx *router.Context) error {
|
||||||
|
return middleware.Authenticated(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle implements router.Route.
|
||||||
|
func (u *UserGET) Handle(ctx *router.Context) error {
|
||||||
|
currentUser := middleware.User(u, ctx)
|
||||||
|
id := ctx.Parameter("id")
|
||||||
|
|
||||||
|
if id == "me" {
|
||||||
|
id = currentUser.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := ctx.Query.GetUser(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.Error(err, http.StatusNotFound, "user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.JSON(200, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ router.AuthorizedRoute = new(UserGET)
|
||||||
|
var _ router.ValidatedParamRoute = new(UserGET)
|
||||||
91
backend/routes/user-tasks.go
Normal file
91
backend/routes/user-tasks.go
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/db"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/middleware"
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
routes["GET"]["/user/{id}/tasks"] = new(UserTasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
type userTasksResponse struct {
|
||||||
|
Tasks []db.Task `json:"tasks"`
|
||||||
|
TaskCategories []db.TaskCategory `json:"task_categories"`
|
||||||
|
Categories []db.Category `json:"categories"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserTasks struct{}
|
||||||
|
|
||||||
|
// Authorize implements router.AuthorizedRoute.
|
||||||
|
func (u *UserTasks) Authorize(ctx *router.Context) error {
|
||||||
|
return middleware.Authenticated(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateParams implements router.ValidatedParamRoute.
|
||||||
|
func (u *UserTasks) ValidateParams(ctx *router.Context) error {
|
||||||
|
if ctx.Parameter("id") == "me" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return middleware.ValidateUUIDParam(ctx, "id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle implements router.Route.
|
||||||
|
func (u *UserTasks) Handle(ctx *router.Context) error {
|
||||||
|
user := middleware.User(u, ctx)
|
||||||
|
id := ctx.Parameter("id")
|
||||||
|
|
||||||
|
if id == "me" {
|
||||||
|
id = user.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := ctx.DB.BeginTx(ctx, &sql.TxOptions{
|
||||||
|
ReadOnly: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
query := ctx.Query.WithTx(tx)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks, err := query.TasksForUser(ctx, id)
|
||||||
|
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
taskIDs := make([]int64, len(tasks))
|
||||||
|
for i, task := range tasks {
|
||||||
|
taskIDs[i] = task.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
taskCategories, err := query.TaskCategoriesOf(ctx, taskIDs)
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
categories, err := query.CategoriesOf(ctx, taskIDs)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return ctx.GenericError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.JSON(200, userTasksResponse{
|
||||||
|
Tasks: append([]db.Task{}, tasks...),
|
||||||
|
TaskCategories: append([]db.TaskCategory{}, taskCategories...),
|
||||||
|
Categories: append([]db.Category{}, categories...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ router.AuthorizedRoute = new(UserTasks)
|
||||||
|
var _ router.ValidatedParamRoute = new(UserTasks)
|
||||||
39
backend/schema.sql
Normal file
39
backend/schema.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password BLOB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
key BLOB PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE,
|
||||||
|
created_at INT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
completed INT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE,
|
||||||
|
assigned_to TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE,
|
||||||
|
created_at INT NOT NULL,
|
||||||
|
deadline INT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_by TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE,
|
||||||
|
created_at INT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS task_categories (
|
||||||
|
task_id INT NOT NULL REFERENCES tasks(id) ON UPDATE CASCADE,
|
||||||
|
category_id INT NOT NULL REFERENCES category(id) ON UPDATE CASCADE,
|
||||||
|
PRIMARY KEY (task_id, category_id)
|
||||||
|
);
|
||||||
36
backend/seed.go
Normal file
36
backend/seed.go
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"git.red-panda.pet/pandaware/house/backend/db"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedDevData(sqlite *sql.DB) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tx := db.New(sqlite)
|
||||||
|
|
||||||
|
id := uuid.Must(uuid.NewV7())
|
||||||
|
|
||||||
|
user, err := tx.CreateUser(ctx, db.CreateUserParams{
|
||||||
|
ID: id.String(),
|
||||||
|
Username: "red",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
password, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
|
||||||
|
|
||||||
|
_, err = tx.UpdateUserPassword(ctx, db.UpdateUserPasswordParams{
|
||||||
|
ID: user.ID,
|
||||||
|
Password: password,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/sqlc.yaml
Normal file
10
backend/sqlc.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
version: "2"
|
||||||
|
sql:
|
||||||
|
- engine: "sqlite"
|
||||||
|
queries: "query.sql"
|
||||||
|
schema: "schema.sql"
|
||||||
|
gen:
|
||||||
|
go:
|
||||||
|
package: "db"
|
||||||
|
out: "db"
|
||||||
|
emit_json_tags: true
|
||||||
26
frontend/.gitignore
vendored
Normal file
26
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
.tanstack
|
||||||
54
frontend/README.md
Normal file
54
frontend/README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default tseslint.config({
|
||||||
|
extends: [
|
||||||
|
// Remove ...tseslint.configs.recommended and replace with this
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
// other options...
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x';
|
||||||
|
import reactDom from 'eslint-plugin-react-dom';
|
||||||
|
|
||||||
|
export default tseslint.config({
|
||||||
|
plugins: {
|
||||||
|
// Add the react-x and react-dom plugins
|
||||||
|
'react-x': reactX,
|
||||||
|
'react-dom': reactDom,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// other rules...
|
||||||
|
// Enable its recommended typescript rules
|
||||||
|
...reactX.configs['recommended-typescript'].rules,
|
||||||
|
...reactDom.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
28
frontend/eslint.config.js
Normal file
28
frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + React + TS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
46
frontend/package.json
Normal file
46
frontend/package.json
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"format": "prettier --write ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
|
"@radix-ui/themes": "^3.2.1",
|
||||||
|
"@tanstack/react-query": "^5.80.7",
|
||||||
|
"@tanstack/react-query-devtools": "^5.80.7",
|
||||||
|
"@tanstack/react-router": "^1.121.12",
|
||||||
|
"@tanstack/react-router-devtools": "^1.121.12",
|
||||||
|
"radix-ui": "^1.4.2",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@tanstack/router-plugin": "^1.121.12",
|
||||||
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
|
"prettier-plugin-organize-imports": "^4.1.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.30.1",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@swc/core",
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
4475
frontend/pnpm-lock.yaml
generated
Normal file
4475
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
201
frontend/src/api.ts
Normal file
201
frontend/src/api.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import {
|
||||||
|
queryOptions,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
created_by: string;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
completed: number;
|
||||||
|
created_by: string;
|
||||||
|
assigned_to: string;
|
||||||
|
created_at: number;
|
||||||
|
deadline?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskCategory {
|
||||||
|
task_id: number;
|
||||||
|
category_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorResponse {
|
||||||
|
request_id: string;
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-query' {
|
||||||
|
interface Register {
|
||||||
|
defaultError: ErrorResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPMethod = 'GET' | 'POST' | 'DELETE';
|
||||||
|
|
||||||
|
type CanHaveBody<M extends HTTPMethod> = M extends 'GET' ? false : true;
|
||||||
|
|
||||||
|
export async function request<M extends HTTPMethod, R = unknown, T = unknown>(
|
||||||
|
method: M,
|
||||||
|
endpoint: string,
|
||||||
|
body?: CanHaveBody<M> extends true ? T : undefined,
|
||||||
|
): Promise<R> {
|
||||||
|
const resp = await fetch(endpoint, {
|
||||||
|
method,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseBody = await resp.json();
|
||||||
|
|
||||||
|
if (resp.status != 200) {
|
||||||
|
throw responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseBody as R;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function whoamiOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: ['user', 'me'],
|
||||||
|
async queryFn() {
|
||||||
|
return request<'GET', User>('GET', '/api/v1/user/me');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCurrentUser() {
|
||||||
|
return useQuery(whoamiOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userOptions(userID: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: ['user', userID],
|
||||||
|
async queryFn() {
|
||||||
|
return request<'GET', User>('GET', '/api/v1/user/' + userID);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUser(userID: string) {
|
||||||
|
return useQuery(userOptions(userID));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userTasksOptions(userID: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: ['user', userID, 'tasks'],
|
||||||
|
async queryFn() {
|
||||||
|
return request<'GET', { tasks: Task[] }>(
|
||||||
|
'GET',
|
||||||
|
'/api/v1/user/' + userID + '/tasks',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserTasks(userID: string) {
|
||||||
|
return useQuery(userTasksOptions(userID));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaskBody {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
deadline?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateTask() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
async mutationFn(body: CreateTaskBody): Promise<{
|
||||||
|
task: Task;
|
||||||
|
}> {
|
||||||
|
return request('POST', '/api/v1/task', body);
|
||||||
|
},
|
||||||
|
onSettled() {
|
||||||
|
queryClient.invalidateQueries(userTasksOptions('me'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCategoryBody {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
color?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateCategory() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
async mutationFn(body: CreateCategoryBody): Promise<{
|
||||||
|
category: Category;
|
||||||
|
}> {
|
||||||
|
return request('POST', '/api/v1/category', body);
|
||||||
|
},
|
||||||
|
onSettled() {
|
||||||
|
queryClient.invalidateQueries(categoriesOptions());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddTaskCategories(taskID: number) {
|
||||||
|
return useMutation({
|
||||||
|
async mutationFn(categoryIDs: number[]) {
|
||||||
|
return request<'POST', { taskCategories: TaskCategory[] }>(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/task/' + taskID + '/categories',
|
||||||
|
{
|
||||||
|
categoryIDs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoriesOptions(enabled?: boolean) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: ['categories'],
|
||||||
|
async queryFn() {
|
||||||
|
return await request<'GET', { categories: Category[] }>(
|
||||||
|
'GET',
|
||||||
|
'/api/v1/category',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCategories(enabled?: boolean) {
|
||||||
|
return useQuery(categoriesOptions(enabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompleteTask(taskID: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
async mutationFn() {
|
||||||
|
return request<'POST', { task: Task }>(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/task' + taskID + '/complete',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSettled() {
|
||||||
|
queryClient.invalidateQueries(userTasksOptions('me'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
17
frontend/src/app.css
Normal file
17
frontend/src/app.css
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-toggle[data-state='open'] {
|
||||||
|
rotate: 180deg;
|
||||||
|
}
|
||||||
51
frontend/src/components/category-select.tsx
Normal file
51
frontend/src/components/category-select.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { PlusIcon } from '@radix-ui/react-icons';
|
||||||
|
import { Button, CheckboxCards, Flex, Popover } from '@radix-ui/themes';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useCategories } from '../api';
|
||||||
|
|
||||||
|
interface CategorySelectProps {
|
||||||
|
categoryIDs: Set<number>;
|
||||||
|
setCategoryIDs(ids: Set<number>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategorySelect({
|
||||||
|
categoryIDs,
|
||||||
|
setCategoryIDs,
|
||||||
|
}: CategorySelectProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const categories = useCategories(open);
|
||||||
|
|
||||||
|
const checked = [...categoryIDs].map((v) => v.toString());
|
||||||
|
|
||||||
|
function setChecked(ids: string[]) {
|
||||||
|
const parsed = ids.map((v) => parseInt(v)).filter(Number.isSafeInteger);
|
||||||
|
setCategoryIDs(new Set(parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||||
|
<Popover.Trigger>
|
||||||
|
<Button>
|
||||||
|
<PlusIcon /> Add categories
|
||||||
|
</Button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content>
|
||||||
|
<Flex asChild direction="column" gap="2">
|
||||||
|
<CheckboxCards.Root
|
||||||
|
value={checked}
|
||||||
|
onValueChange={setChecked}
|
||||||
|
>
|
||||||
|
{categories.data?.categories.map((c) => (
|
||||||
|
<CheckboxCards.Item value={c.id.toString()}>
|
||||||
|
{c.name}
|
||||||
|
</CheckboxCards.Item>
|
||||||
|
))}
|
||||||
|
</CheckboxCards.Root>
|
||||||
|
</Flex>
|
||||||
|
<Popover.Close>
|
||||||
|
<Button>Done</Button>
|
||||||
|
</Popover.Close>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
frontend/src/components/create-category.tsx
Normal file
90
frontend/src/components/create-category.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import {
|
||||||
|
CardStackPlusIcon,
|
||||||
|
CrossCircledIcon,
|
||||||
|
Pencil1Icon,
|
||||||
|
PlusIcon,
|
||||||
|
} from '@radix-ui/react-icons';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Callout,
|
||||||
|
Dialog,
|
||||||
|
Flex,
|
||||||
|
Grid,
|
||||||
|
Spinner,
|
||||||
|
TextField,
|
||||||
|
} from '@radix-ui/themes';
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useCreateCategory } from '../api';
|
||||||
|
|
||||||
|
export default function CreateCategory() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const createCategory = useCreateCategory();
|
||||||
|
async function submit(ev: FormEvent<HTMLFormElement>) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
await createCategory.mutateAsync({
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||||
|
<Dialog.Trigger>
|
||||||
|
<Button variant="soft">
|
||||||
|
<CardStackPlusIcon />
|
||||||
|
Create category
|
||||||
|
</Button>
|
||||||
|
</Dialog.Trigger>
|
||||||
|
<Dialog.Content maxWidth="400px">
|
||||||
|
<Dialog.Title>Create a new category</Dialog.Title>
|
||||||
|
<Dialog.Description></Dialog.Description>
|
||||||
|
{createCategory.isError && (
|
||||||
|
<Callout.Root>
|
||||||
|
<Callout.Icon>
|
||||||
|
<CrossCircledIcon />
|
||||||
|
</Callout.Icon>
|
||||||
|
<Callout.Text>
|
||||||
|
{createCategory.error.message}
|
||||||
|
</Callout.Text>
|
||||||
|
</Callout.Root>
|
||||||
|
)}
|
||||||
|
<Flex asChild direction="column" gap="4">
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<TextField.Root
|
||||||
|
placeholder="Category name..."
|
||||||
|
value={name}
|
||||||
|
onChange={(ev) => setName(ev.currentTarget.value)}
|
||||||
|
>
|
||||||
|
<TextField.Slot>
|
||||||
|
<Pencil1Icon />
|
||||||
|
</TextField.Slot>
|
||||||
|
</TextField.Root>
|
||||||
|
|
||||||
|
<Grid columns="2" gap="2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={createCategory.isPending}
|
||||||
|
>
|
||||||
|
<Spinner loading={createCategory.isPending}>
|
||||||
|
<PlusIcon />
|
||||||
|
</Spinner>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
<Dialog.Close>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="soft"
|
||||||
|
color="gray"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Dialog.Close>
|
||||||
|
</Grid>
|
||||||
|
</form>
|
||||||
|
</Flex>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
frontend/src/components/create-task.tsx
Normal file
85
frontend/src/components/create-task.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { CalendarIcon, PlusIcon } from '@radix-ui/react-icons';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
Flex,
|
||||||
|
Grid,
|
||||||
|
TextArea,
|
||||||
|
TextField,
|
||||||
|
} from '@radix-ui/themes';
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useCreateTask } from '../api';
|
||||||
|
|
||||||
|
export default function CreateTask() {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [deadline, _setDeadline] = useState(0);
|
||||||
|
const createMutation = useCreateTask();
|
||||||
|
|
||||||
|
async function submit(ev: FormEvent<HTMLFormElement>) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
await createMutation.mutateAsync({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
deadline: deadline > 0 ? deadline : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root>
|
||||||
|
<Dialog.Trigger>
|
||||||
|
<Button>
|
||||||
|
<PlusIcon />
|
||||||
|
Create task
|
||||||
|
</Button>
|
||||||
|
</Dialog.Trigger>
|
||||||
|
<Dialog.Content size="3" maxWidth="400px">
|
||||||
|
<Flex direction="column" gap="2">
|
||||||
|
<Dialog.Title>Create a new task</Dialog.Title>
|
||||||
|
<Dialog.Description></Dialog.Description>
|
||||||
|
<Flex direction="column" gap="4" asChild>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<TextField.Root
|
||||||
|
placeholder="Task name"
|
||||||
|
value={name}
|
||||||
|
onChange={(ev) =>
|
||||||
|
setName(ev.currentTarget.value)
|
||||||
|
}
|
||||||
|
></TextField.Root>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
placeholder="Description... (optional)"
|
||||||
|
value={description}
|
||||||
|
onChange={(ev) =>
|
||||||
|
setDescription(ev.currentTarget.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField.Root type="datetime-local">
|
||||||
|
<TextField.Slot>
|
||||||
|
<CalendarIcon />
|
||||||
|
</TextField.Slot>
|
||||||
|
</TextField.Root>
|
||||||
|
<Grid gap="2" columns="2">
|
||||||
|
<Button type="submit">
|
||||||
|
<PlusIcon />
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
<Dialog.Close>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="soft"
|
||||||
|
color="gray"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Dialog.Close>
|
||||||
|
</Grid>
|
||||||
|
</form>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
frontend/src/components/task-card.tsx
Normal file
54
frontend/src/components/task-card.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Checkbox,
|
||||||
|
ChevronDownIcon,
|
||||||
|
Flex,
|
||||||
|
IconButton,
|
||||||
|
Text,
|
||||||
|
} from '@radix-ui/themes';
|
||||||
|
import { Accordion } from 'radix-ui';
|
||||||
|
import { type Task } from '../api';
|
||||||
|
import UserAvatar from './user-avatar';
|
||||||
|
|
||||||
|
interface TaskCardProps {
|
||||||
|
task: Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskCard({ task }: TaskCardProps) {
|
||||||
|
// const completeTask = useCompleteTask(task.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Accordion.Item value={task.id.toString()}>
|
||||||
|
<Accordion.Header>
|
||||||
|
<Flex direction="row" align="center" gap="4">
|
||||||
|
<UserAvatar userID={task.assigned_to} />
|
||||||
|
<Flex flexGrow="1" direction="column">
|
||||||
|
<Text>{task.name}</Text>
|
||||||
|
</Flex>
|
||||||
|
<Checkbox checked={task.completed === 1} size="3" />
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
px="3"
|
||||||
|
py="2"
|
||||||
|
my="auto"
|
||||||
|
>
|
||||||
|
<Accordion.Trigger asChild>
|
||||||
|
<IconButton
|
||||||
|
className="accordion-toggle"
|
||||||
|
size="4"
|
||||||
|
color="gray"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Accordion.Header>
|
||||||
|
<Accordion.Content>hi</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/src/components/user-avatar.tsx
Normal file
16
frontend/src/components/user-avatar.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Avatar, Skeleton } from '@radix-ui/themes';
|
||||||
|
import { useUser } from '../api';
|
||||||
|
|
||||||
|
interface UserAvatarProps {
|
||||||
|
userID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserAvatar({ userID }: UserAvatarProps) {
|
||||||
|
const user = useUser(userID);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Skeleton loading={user.isPending}>
|
||||||
|
<Avatar fallback={user.data?.username.charAt(0) ?? '?'} />
|
||||||
|
</Skeleton>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/components/user-card.tsx
Normal file
30
frontend/src/components/user-card.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { ExitIcon } from '@radix-ui/react-icons';
|
||||||
|
import { Button, Card, Flex, Skeleton, Spinner, Text } from '@radix-ui/themes';
|
||||||
|
import { useUser } from '../api';
|
||||||
|
import UserAvatar from './user-avatar';
|
||||||
|
|
||||||
|
interface UserCardProps {
|
||||||
|
userID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserCard({ userID }: UserCardProps) {
|
||||||
|
const { data: user } = useUser(userID);
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Flex pr="1" direction="row" gap="2" align="center">
|
||||||
|
<Skeleton loading={!user}>
|
||||||
|
<UserAvatar userID={userID} />
|
||||||
|
</Skeleton>
|
||||||
|
<Skeleton loading={!user}>
|
||||||
|
<Text>{user?.username}</Text>
|
||||||
|
</Skeleton>
|
||||||
|
<Button variant="soft" ml="auto" disabled={!user}>
|
||||||
|
<Spinner loading={!user}>
|
||||||
|
<ExitIcon />
|
||||||
|
</Spinner>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/src/main.tsx
Normal file
41
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { Theme } from '@radix-ui/themes';
|
||||||
|
import './app.css';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { RouterProvider, createRouter } from '@tanstack/react-router';
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { routeTree } from './routeTree.gen';
|
||||||
|
|
||||||
|
import '@radix-ui/themes/styles.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({});
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: {
|
||||||
|
queryClient,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Theme
|
||||||
|
appearance="dark"
|
||||||
|
panelBackground="solid"
|
||||||
|
accentColor="ruby"
|
||||||
|
grayColor="auto"
|
||||||
|
// radius="full"
|
||||||
|
radius="large"
|
||||||
|
>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</Theme>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
77
frontend/src/routeTree.gen.ts
Normal file
77
frontend/src/routeTree.gen.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
|
||||||
|
// This file was automatically generated by TanStack Router.
|
||||||
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
|
||||||
|
const LoginRoute = LoginRouteImport.update({
|
||||||
|
id: '/login',
|
||||||
|
path: '/login',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths: '/' | '/login'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to: '/' | '/login'
|
||||||
|
id: '__root__' | '/' | '/login'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
IndexRoute: typeof IndexRoute
|
||||||
|
LoginRoute: typeof LoginRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
'/login': {
|
||||||
|
id: '/login'
|
||||||
|
path: '/login'
|
||||||
|
fullPath: '/login'
|
||||||
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/': {
|
||||||
|
id: '/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
LoginRoute: LoginRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
25
frontend/src/routes/__root.tsx
Normal file
25
frontend/src/routes/__root.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { ThemePanel } from '@radix-ui/themes';
|
||||||
|
import type { QueryClient } from '@tanstack/react-query';
|
||||||
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router';
|
||||||
|
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export interface RootContext {
|
||||||
|
queryClient: QueryClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRootRouteWithContext<RootContext>()({
|
||||||
|
component: RootComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RootComponent() {
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<Outlet />
|
||||||
|
<TanStackRouterDevtools />
|
||||||
|
<ThemePanel defaultOpen={false} />
|
||||||
|
<ReactQueryDevtools />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
frontend/src/routes/index.tsx
Normal file
38
frontend/src/routes/index.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Container, Flex, Grid } from '@radix-ui/themes';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { Accordion } from 'radix-ui';
|
||||||
|
import { useUserTasks } from '../api';
|
||||||
|
|
||||||
|
import CreateCategory from '../components/create-category';
|
||||||
|
import CreateTask from '../components/create-task';
|
||||||
|
import TaskCard from '../components/task-card';
|
||||||
|
import UserCard from '../components/user-card';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/')({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const tasks = useUserTasks('me');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" width="100vw" height="100vh">
|
||||||
|
<Container size="1" p="8">
|
||||||
|
<Flex direction="column" gap="4">
|
||||||
|
<UserCard userID="me" />
|
||||||
|
<Grid gap="2" columns="2">
|
||||||
|
<CreateTask />
|
||||||
|
<CreateCategory />
|
||||||
|
</Grid>
|
||||||
|
<Flex direction="column" gap="2" asChild>
|
||||||
|
<Accordion.Root type="single" collapsible>
|
||||||
|
{tasks.data?.tasks.map((t) => (
|
||||||
|
<TaskCard key={t.id} task={t} />
|
||||||
|
))}
|
||||||
|
</Accordion.Root>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
frontend/src/routes/login.tsx
Normal file
81
frontend/src/routes/login.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { LockClosedIcon, PersonIcon } from '@radix-ui/react-icons';
|
||||||
|
import { Button, Card, Flex, TextField } from '@radix-ui/themes';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { request, type User } from '../api';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/login')({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const loginMutation = useMutation({
|
||||||
|
async mutationFn() {
|
||||||
|
return await request<'POST', { user: User }>(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/login',
|
||||||
|
{
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
navigate({ to: '/' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit(ev: FormEvent<HTMLFormElement>) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
await loginMutation.mutateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
justify="center"
|
||||||
|
align="center"
|
||||||
|
direction="column"
|
||||||
|
width="100vw"
|
||||||
|
height="100vh"
|
||||||
|
>
|
||||||
|
<Card size="2" variant="classic">
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<Flex gap="3" direction="column" position="relative">
|
||||||
|
<TextField.Root
|
||||||
|
placeholder="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={(ev) =>
|
||||||
|
setUsername(ev.currentTarget.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TextField.Slot>
|
||||||
|
<PersonIcon />
|
||||||
|
</TextField.Slot>
|
||||||
|
</TextField.Root>
|
||||||
|
<TextField.Root
|
||||||
|
placeholder="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(ev) =>
|
||||||
|
setPassword(ev.currentTarget.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TextField.Slot>
|
||||||
|
<LockClosedIcon />
|
||||||
|
</TextField.Slot>
|
||||||
|
</TextField.Root>
|
||||||
|
<Button type="submit" loading={loginMutation.isPending}>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
27
frontend/tsconfig.app.json
Normal file
27
frontend/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
frontend/tsconfig.node.json
Normal file
25
frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
13
frontend/vite.config.ts
Normal file
13
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react-swc';
|
||||||
|
import { tanstackRouter } from '@tanstack/router-plugin/vite';
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
tanstackRouter({
|
||||||
|
target: 'react',
|
||||||
|
}),
|
||||||
|
react(),
|
||||||
|
],
|
||||||
|
});
|
||||||
29
go.mod
Normal file
29
go.mod
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
module git.red-panda.pet/pandaware/house
|
||||||
|
|
||||||
|
go 1.23.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.red-panda.pet/pandaware/lipgloss-catppuccin v0.0.0-20250608181442-a48744fcd663
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/charmbracelet/log v0.4.2
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.28
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/crypto v0.39.0
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||||
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
|
)
|
||||||
50
go.sum
Normal file
50
go.sum
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
git.red-panda.pet/pandaware/lipgloss-catppuccin v0.0.0-20250608181442-a48744fcd663 h1:jgxkCPBt+XdRvo1RPBcaGfF3X77hLg9SexLXZm2I2A0=
|
||||||
|
git.red-panda.pet/pandaware/lipgloss-catppuccin v0.0.0-20250608181442-a48744fcd663/go.mod h1:OsoRM6jK2N0aX3A3rDDGq28abo2b65uaIyb6ivtxoDI=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||||
|
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||||
|
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||||
|
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
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/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||||
|
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||||
|
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
Loading…
Add table
Add a link
Reference in a new issue