initial commit
This commit is contained in:
commit
d40b69f1f9
58 changed files with 7919 additions and 0 deletions
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue