Files
verdande/internal/store/projects.go
T
Kristian WindandClaude Opus 5 8a6e1bc39e feat(projects): rename, delete, and a trash you can actually reach
A project could be created and shared and never renamed or deleted. Both
have worked through the API since the beginning; there was simply nothing
to click.

The heading is now the field — a heading that happens to be editable
rather than a form that looks like one — and saves on blur, with Escape
putting the old name back. Owner only, and the Inbox keeps its plain
heading because it cannot be either renamed away or deleted.

Deleting needed more than a button. It is soft, with thirty days to
change your mind, and restoring has been possible the whole time — from
an id, which is exactly what you no longer have once the project has gone
from the screen. A recovery window nobody can reach is not one. So
GET /trash/projects lists what you have deleted, with how many tasks went
with it and how long is left, and Settings → Data has the button.

Owned, not shared: a member of a project somebody else deleted has no
business bringing it back, and there is a test that says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:02:41 +02:00

559 lines
16 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"time"
)
type Project struct {
ID string
Name string
Color string
Icon string
ViewMode string
OwnerID string
IsInbox bool
Archived bool
SortOrder float64
CreatedAt time.Time
UpdatedAt time.Time
// Role is the asking user's standing, filled in by the list and get calls so
// the frontend can grey out what they cannot do without a second request.
Role Role
// MemberCount is how many people can see it; 1 means it is not shared.
MemberCount int
}
type Section struct {
ID string
ProjectID string
Name string
SortOrder float64
CreatedAt time.Time
UpdatedAt time.Time
}
// ListProjects returns everything the user can see: what they own, plus what has
// been shared with them. The Inbox sorts first, then by sort_order — the Inbox is
// not a project anyone chose to place, so it does not compete for a position.
func (db *DB) ListProjects(ctx context.Context, userID string, includeArchived bool) ([]Project, error) {
query := `
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
p.archived, p.sort_order, p.created_at, p.updated_at,
CASE WHEN p.owner_id = ? THEN 'owner' ELSE COALESCE(m.role, '') END AS role,
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1 AS member_count
FROM projects p
LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = ?
WHERE p.deleted_at IS NULL
AND (p.owner_id = ? OR m.user_id IS NOT NULL)`
if !includeArchived {
query += ` AND p.archived = 0`
}
query += ` ORDER BY p.is_inbox DESC, p.sort_order, p.created_at`
rows, err := db.QueryContext(ctx, query, userID, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
projects := []Project{}
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, err
}
projects = append(projects, p)
}
return projects, rows.Err()
}
func (db *DB) GetProject(ctx context.Context, projectID, userID string) (*Project, error) {
role, err := ProjectRole(ctx, db, projectID, userID)
if err != nil {
return nil, err
}
rows, err := db.QueryContext(ctx, `
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
p.archived, p.sort_order, p.created_at, p.updated_at,
? AS role,
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1
FROM projects p WHERE p.id = ? AND p.deleted_at IS NULL`, string(role), projectID)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, ErrNotFound
}
p, err := scanProject(rows)
if err != nil {
return nil, err
}
return &p, rows.Err()
}
func scanProject(rows *sql.Rows) (Project, error) {
var p Project
var icon sql.NullString
var isInbox, archived int
var created, updated int64
var role string
err := rows.Scan(&p.ID, &p.Name, &p.Color, &icon, &p.ViewMode, &p.OwnerID, &isInbox,
&archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount)
if err != nil {
return p, err
}
p.Icon = icon.String
p.IsInbox = isInbox == 1
p.Archived = archived == 1
p.CreatedAt = time.Unix(created, 0).UTC()
p.UpdatedAt = time.Unix(updated, 0).UTC()
p.Role = Role(role)
return p, nil
}
func (db *DB) CreateProject(ctx context.Context, p *Project) error {
if p.ID == "" {
p.ID = NewID()
}
now := time.Now().UTC()
p.CreatedAt, p.UpdatedAt = now, now
if p.ViewMode == "" {
p.ViewMode = "list"
}
if p.Color == "" {
p.Color = "graphite"
}
if p.SortOrder == 0 {
order, err := db.nextProjectOrder(ctx, p.OwnerID)
if err != nil {
return err
}
p.SortOrder = order
}
var icon any
if p.Icon != "" {
icon = p.Icon
}
_, err := db.ExecContext(ctx,
`INSERT INTO projects (id, name, color, icon, view_mode, owner_id, is_inbox,
archived, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)`,
p.ID, p.Name, p.Color, icon, p.ViewMode, p.OwnerID, p.SortOrder,
now.Unix(), now.Unix())
p.Role = RoleOwner
p.MemberCount = 1
return err
}
func (db *DB) nextProjectOrder(ctx context.Context, ownerID string) (float64, error) {
var max sql.NullFloat64
err := db.QueryRowContext(ctx,
`SELECT max(sort_order) FROM projects WHERE owner_id = ? AND deleted_at IS NULL`,
ownerID).Scan(&max)
if err != nil {
return 0, err
}
return max.Float64 + 1024, nil
}
// ProjectUpdate carries only the fields that were actually sent. A nil pointer
// means "not mentioned", which is what makes PATCH different from PUT — without
// it, a client updating a name would blank every field it did not send.
type ProjectUpdate struct {
Name *string
Color *string
Icon *string
ViewMode *string
Archived *bool
SortOrder *float64
}
func (db *DB) UpdateProject(ctx context.Context, projectID string, u ProjectUpdate) error {
set, args := buildUpdate(map[string]any{
"name": u.Name,
"color": u.Color,
"icon": u.Icon,
"view_mode": u.ViewMode,
"archived": u.Archived,
"sort_order": u.SortOrder,
})
if len(set) == 0 {
return nil
}
set = append(set, "updated_at = ?")
args = append(args, time.Now().Unix(), projectID)
res, err := db.ExecContext(ctx,
`UPDATE projects SET `+joinComma(set)+` WHERE id = ? AND deleted_at IS NULL`, args...)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteProject is a soft delete: the project goes to the trash and everything in
// it goes with it, so restoring brings back a project that still has its tasks.
func (db *DB) DeleteProject(ctx context.Context, projectID string) error {
now := time.Now().Unix()
return db.Tx(ctx, func(tx *sql.Tx) error {
var isInbox int
err := tx.QueryRowContext(ctx,
`SELECT is_inbox FROM projects WHERE id = ? AND deleted_at IS NULL`, projectID).Scan(&isInbox)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
// The Inbox is where tasks with no project go. Deleting it would leave
// quick add with nowhere to write.
if isInbox == 1 {
return errors.New("store: the Inbox cannot be deleted")
}
if _, err := tx.ExecContext(ctx,
`UPDATE tasks SET deleted_at = ? WHERE project_id = ? AND deleted_at IS NULL`,
now, projectID); err != nil {
return err
}
_, err = tx.ExecContext(ctx,
`UPDATE projects SET deleted_at = ?, updated_at = ? WHERE id = ?`, now, now, projectID)
return err
})
}
// TrashedProject is a deleted project, with when it went and when it goes for good.
type TrashedProject struct {
Project
DeletedAt time.Time
TaskCount int
}
// ListTrashedProjects returns what the caller owns and has deleted.
//
// Restoring has been possible since the trash existed, but only if you already
// knew the project's id — which is exactly what you no longer have once it has
// gone from the interface. A recovery window nobody can reach is not one.
//
// Owned, not shared: a member of a project somebody else deleted has no business
// bringing it back.
func (db *DB) ListTrashedProjects(ctx context.Context, userID string) ([]TrashedProject, error) {
rows, err := db.QueryContext(ctx, `
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
p.archived, p.sort_order, p.created_at, p.updated_at, p.deleted_at,
(SELECT count(*) FROM tasks t
WHERE t.project_id = p.id AND t.deleted_at = p.deleted_at) AS task_count
FROM projects p
WHERE p.owner_id = ? AND p.deleted_at IS NOT NULL
ORDER BY p.deleted_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []TrashedProject{}
for rows.Next() {
var t TrashedProject
var created, updated, deleted int64
var icon sql.NullString
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &icon, &t.ViewMode, &t.OwnerID,
&t.IsInbox, &t.Archived, &t.SortOrder, &created, &updated, &deleted,
&t.TaskCount); err != nil {
return nil, err
}
t.Icon = icon.String
t.CreatedAt = time.Unix(created, 0).UTC()
t.UpdatedAt = time.Unix(updated, 0).UTC()
t.DeletedAt = time.Unix(deleted, 0).UTC()
t.Role = RoleOwner
out = append(out, t)
}
return out, rows.Err()
}
// RestoreProject brings a project back, along with the tasks that were deleted with
// it. Tasks deleted before it are left in the trash: they were deleted on purpose
// and separately, and restoring a project should not undo that too.
func (db *DB) RestoreProject(ctx context.Context, projectID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
var deletedAt sql.NullInt64
err := tx.QueryRowContext(ctx,
`SELECT deleted_at FROM projects WHERE id = ?`, projectID).Scan(&deletedAt)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if !deletedAt.Valid {
return nil // already live
}
if _, err := tx.ExecContext(ctx,
`UPDATE tasks SET deleted_at = NULL WHERE project_id = ? AND deleted_at = ?`,
projectID, deletedAt.Int64); err != nil {
return err
}
_, err = tx.ExecContext(ctx,
`UPDATE projects SET deleted_at = NULL, updated_at = ? WHERE id = ?`,
time.Now().Unix(), projectID)
return err
})
}
// --- members ------------------------------------------------------------------
type Member struct {
UserID string
Email string
Name string
AvatarColor string
Role Role
AddedAt time.Time
}
// ListMembers returns everyone with access, owner included. The owner has no
// project_members row — ownership is the projects table — so they are unioned in
// rather than left out of the list of people who can see it.
func (db *DB) ListMembers(ctx context.Context, projectID string) ([]Member, error) {
rows, err := db.QueryContext(ctx, `
SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role, p.created_at
FROM projects p JOIN users u ON u.id = p.owner_id
WHERE p.id = ?
UNION ALL
SELECT u.id, u.email, u.name, u.avatar_color, m.role, m.added_at
FROM project_members m JOIN users u ON u.id = m.user_id
WHERE m.project_id = ?
ORDER BY role = 'owner' DESC, name`, projectID, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
members := []Member{}
for rows.Next() {
var m Member
var added int64
if err := rows.Scan(&m.UserID, &m.Email, &m.Name, &m.AvatarColor, &m.Role, &added); err != nil {
return nil, err
}
m.AddedAt = time.Unix(added, 0).UTC()
members = append(members, m)
}
return members, rows.Err()
}
func (db *DB) AddMember(ctx context.Context, projectID, userID string, role Role) error {
if !role.Valid() || role == RoleOwner {
// Ownership is transferred, not granted: two owners is a state with no
// way to resolve a disagreement about who may remove whom.
return errors.New("store: members may be editor or viewer")
}
_, err := db.ExecContext(ctx,
`INSERT INTO project_members (project_id, user_id, role, added_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (project_id, user_id) DO UPDATE SET role = excluded.role`,
projectID, userID, string(role), time.Now().Unix())
return err
}
func (db *DB) RemoveMember(ctx context.Context, projectID, userID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
// Anything assigned to them is unassigned rather than left pointing at
// somebody who can no longer see the project.
if _, err := tx.ExecContext(ctx,
`UPDATE tasks SET assignee_id = NULL WHERE project_id = ? AND assignee_id = ?`,
projectID, userID); err != nil {
return err
}
res, err := tx.ExecContext(ctx,
`DELETE FROM project_members WHERE project_id = ? AND user_id = ?`, projectID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
})
}
// --- sections -----------------------------------------------------------------
func (db *DB) ListSections(ctx context.Context, projectID string) ([]Section, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, project_id, name, sort_order, created_at, updated_at
FROM sections WHERE project_id = ? AND deleted_at IS NULL
ORDER BY sort_order, created_at`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
sections := []Section{}
for rows.Next() {
var s Section
var created, updated int64
if err := rows.Scan(&s.ID, &s.ProjectID, &s.Name, &s.SortOrder, &created, &updated); err != nil {
return nil, err
}
s.CreatedAt = time.Unix(created, 0).UTC()
s.UpdatedAt = time.Unix(updated, 0).UTC()
sections = append(sections, s)
}
return sections, rows.Err()
}
func (db *DB) CreateSection(ctx context.Context, s *Section) error {
if s.ID == "" {
s.ID = NewID()
}
now := time.Now().UTC()
s.CreatedAt, s.UpdatedAt = now, now
if s.SortOrder == 0 {
var max sql.NullFloat64
if err := db.QueryRowContext(ctx,
`SELECT max(sort_order) FROM sections WHERE project_id = ? AND deleted_at IS NULL`,
s.ProjectID).Scan(&max); err != nil {
return err
}
s.SortOrder = max.Float64 + 1024
}
_, err := db.ExecContext(ctx,
`INSERT INTO sections (id, project_id, name, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
s.ID, s.ProjectID, s.Name, s.SortOrder, now.Unix(), now.Unix())
return err
}
type SectionUpdate struct {
Name *string
SortOrder *float64
}
func (db *DB) UpdateSection(ctx context.Context, sectionID string, u SectionUpdate) error {
set, args := buildUpdate(map[string]any{"name": u.Name, "sort_order": u.SortOrder})
if len(set) == 0 {
return nil
}
set = append(set, "updated_at = ?")
args = append(args, time.Now().Unix(), sectionID)
res, err := db.ExecContext(ctx,
`UPDATE sections SET `+joinComma(set)+` WHERE id = ? AND deleted_at IS NULL`, args...)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteSection keeps the tasks and moves them to the project's unsectioned area.
// Deleting a heading is not a request to delete the work filed under it.
func (db *DB) DeleteSection(ctx context.Context, sectionID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx,
`UPDATE tasks SET section_id = NULL WHERE section_id = ?`, sectionID); err != nil {
return err
}
res, err := tx.ExecContext(ctx,
`UPDATE sections SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`,
time.Now().Unix(), sectionID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
})
}
// SectionProject reports which project a section belongs to, so a handler can check
// permission against the project before touching the section.
func (db *DB) SectionProject(ctx context.Context, sectionID string) (string, error) {
var projectID string
err := db.QueryRowContext(ctx,
`SELECT project_id FROM sections WHERE id = ? AND deleted_at IS NULL`, sectionID).Scan(&projectID)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return projectID, err
}
// --- helpers ------------------------------------------------------------------
// buildUpdate turns a map of optional pointers into SET clauses, skipping the ones
// that were not supplied. Keys are literals from the call site, never user input.
func buildUpdate(fields map[string]any) ([]string, []any) {
// Sorted so the generated SQL is stable, which keeps SQLite's statement cache
// working and makes the queries readable in a log.
keys := sortedKeys(fields)
var set []string
var args []any
for _, k := range keys {
switch v := fields[k].(type) {
case *string:
if v != nil {
set = append(set, k+" = ?")
args = append(args, *v)
}
case *float64:
if v != nil {
set = append(set, k+" = ?")
args = append(args, *v)
}
case *int:
if v != nil {
set = append(set, k+" = ?")
args = append(args, *v)
}
case *bool:
if v != nil {
set = append(set, k+" = ?")
args = append(args, boolToInt(*v))
}
}
}
return set, args
}
func sortedKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
for i := 1; i < len(keys); i++ {
for j := i; j > 0 && keys[j] < keys[j-1]; j-- {
keys[j], keys[j-1] = keys[j-1], keys[j]
}
}
return keys
}
func joinComma(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += ", "
}
out += p
}
return out
}