initial commit

This commit is contained in:
basil 2025-06-14 23:47:44 -04:00
commit d40b69f1f9
Signed by: basil
SSH key fingerprint: SHA256:y04xIFL/yqNaG9ae9Vl95vELtHfApGAIoOGLeVLP/fE
58 changed files with 7919 additions and 0 deletions

1
backend/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
tmp

31
backend/db/db.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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)
}
}

View 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
}

View file

@ -0,0 +1,10 @@
package middleware
type middlewareContextKey int
const (
sessionKey middlewareContextKey = iota
userKey
jsonBodyKey
paramsKey
)

View 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
}

View 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
View 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
View 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
View 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)

View 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)

View 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
View 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
View 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)
}
}
}

View 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)

View 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)

View 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)

View 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)

View 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)

View 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
View 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
View 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
View 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