Compare commits

..

3 Commits

14 changed files with 490 additions and 24 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
data/ data/
!server/internal/data
TASKS.md TASKS.md
.env .env
.DS_Store .DS_Store

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.28.0 // sqlc v1.29.0
package data package data

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.28.0 // sqlc v1.29.0
package data package data
@ -15,6 +15,7 @@ type Note struct {
UserID uuid.UUID `json:"user_id"` UserID uuid.UUID `json:"user_id"`
CurrentVersion int32 `json:"current_version"` CurrentVersion int32 `json:"current_version"`
LatestVersion int32 `json:"latest_version"` LatestVersion int32 `json:"latest_version"`
ExpiresAt *time.Time `json:"expires_at"`
CreatedAt *time.Time `json:"created_at"` CreatedAt *time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at"` UpdatedAt *time.Time `json:"updated_at"`
} }

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.28.0 // sqlc v1.29.0
// source: note_versions.sql // source: note_versions.sql
package data package data

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.28.0 // sqlc v1.29.0
// source: notes.sql // source: notes.sql
package data package data
@ -15,7 +15,7 @@ import (
const createNote = `-- name: CreateNote :one const createNote = `-- name: CreateNote :one
INSERT INTO notes (user_id) INSERT INTO notes (user_id)
VALUES ($1) VALUES ($1)
RETURNING id, user_id, current_version, latest_version, created_at, updated_at RETURNING id, user_id, current_version, latest_version, expires_at, created_at, updated_at
` `
func (q *Queries) CreateNote(ctx context.Context, userID uuid.UUID) (Note, error) { func (q *Queries) CreateNote(ctx context.Context, userID uuid.UUID) (Note, error) {
@ -26,12 +26,23 @@ func (q *Queries) CreateNote(ctx context.Context, userID uuid.UUID) (Note, error
&i.UserID, &i.UserID,
&i.CurrentVersion, &i.CurrentVersion,
&i.LatestVersion, &i.LatestVersion,
&i.ExpiresAt,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
return i, err return i, err
} }
const deleteExpiredNotes = `-- name: DeleteExpiredNotes :exec
DELETE FROM notes
WHERE expires_at < NOW()
`
func (q *Queries) DeleteExpiredNotes(ctx context.Context) error {
_, err := q.db.Exec(ctx, deleteExpiredNotes)
return err
}
const deleteNote = `-- name: DeleteNote :exec const deleteNote = `-- name: DeleteNote :exec
DELETE FROM notes DELETE FROM notes
WHERE id = $1 AND user_id = $2 WHERE id = $1 AND user_id = $2
@ -55,6 +66,7 @@ SELECT
nv.content, nv.content,
nv.version_number, nv.version_number,
nv.created_at AS version_created_at, nv.created_at AS version_created_at,
n.expires_at AS note_expires_at,
n.created_at AS note_created_at, n.created_at AS note_created_at,
n.updated_at AS note_updated_at n.updated_at AS note_updated_at
FROM notes n FROM notes n
@ -70,6 +82,7 @@ type GetFullNoteRow struct {
Content string `json:"content"` Content string `json:"content"`
VersionNumber int32 `json:"version_number"` VersionNumber int32 `json:"version_number"`
VersionCreatedAt *time.Time `json:"version_created_at"` VersionCreatedAt *time.Time `json:"version_created_at"`
NoteExpiresAt *time.Time `json:"note_expires_at"`
NoteCreatedAt *time.Time `json:"note_created_at"` NoteCreatedAt *time.Time `json:"note_created_at"`
NoteUpdatedAt *time.Time `json:"note_updated_at"` NoteUpdatedAt *time.Time `json:"note_updated_at"`
} }
@ -84,17 +97,64 @@ func (q *Queries) GetFullNote(ctx context.Context, id uuid.UUID) (GetFullNoteRow
&i.Content, &i.Content,
&i.VersionNumber, &i.VersionNumber,
&i.VersionCreatedAt, &i.VersionCreatedAt,
&i.NoteExpiresAt,
&i.NoteCreatedAt, &i.NoteCreatedAt,
&i.NoteUpdatedAt, &i.NoteUpdatedAt,
) )
return i, err return i, err
} }
const listExpiredNotes = `-- name: ListExpiredNotes :many
SELECT
n.id AS note_id,
n.user_id AS owner_id,
nv.title,
n.expires_at
FROM notes n
JOIN note_versions nv
ON n.id = nv.note_id AND n.current_version = nv.version_number
WHERE n.expires_at <= NOW()
ORDER BY n.expires_at
`
type ListExpiredNotesRow struct {
NoteID uuid.UUID `json:"note_id"`
OwnerID uuid.UUID `json:"owner_id"`
Title string `json:"title"`
ExpiresAt *time.Time `json:"expires_at"`
}
func (q *Queries) ListExpiredNotes(ctx context.Context) ([]ListExpiredNotesRow, error) {
rows, err := q.db.Query(ctx, listExpiredNotes)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListExpiredNotesRow
for rows.Next() {
var i ListExpiredNotesRow
if err := rows.Scan(
&i.NoteID,
&i.OwnerID,
&i.Title,
&i.ExpiresAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listNotes = `-- name: ListNotes :many const listNotes = `-- name: ListNotes :many
SELECT SELECT
n.id AS note_id, n.id AS note_id,
n.user_id AS owner_id, n.user_id AS owner_id,
nv.title, nv.title,
n.expires_at,
n.updated_at n.updated_at
FROM notes n FROM notes n
JOIN note_versions nv JOIN note_versions nv
@ -114,6 +174,7 @@ type ListNotesRow struct {
NoteID uuid.UUID `json:"note_id"` NoteID uuid.UUID `json:"note_id"`
OwnerID uuid.UUID `json:"owner_id"` OwnerID uuid.UUID `json:"owner_id"`
Title string `json:"title"` Title string `json:"title"`
ExpiresAt *time.Time `json:"expires_at"`
UpdatedAt *time.Time `json:"updated_at"` UpdatedAt *time.Time `json:"updated_at"`
} }
@ -130,6 +191,7 @@ func (q *Queries) ListNotes(ctx context.Context, arg ListNotesParams) ([]ListNot
&i.NoteID, &i.NoteID,
&i.OwnerID, &i.OwnerID,
&i.Title, &i.Title,
&i.ExpiresAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
@ -141,3 +203,20 @@ func (q *Queries) ListNotes(ctx context.Context, arg ListNotesParams) ([]ListNot
} }
return items, nil return items, nil
} }
const setNoteExpiration = `-- name: SetNoteExpiration :exec
UPDATE notes
SET expires_at = $1, updated_at = NOW()
WHERE id = $2 AND user_id = $3
`
type SetNoteExpirationParams struct {
ExpiresAt *time.Time `json:"expires_at"`
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) SetNoteExpiration(ctx context.Context, arg SetNoteExpirationParams) error {
_, err := q.db.Exec(ctx, setNoteExpiration, arg.ExpiresAt, arg.ID, arg.UserID)
return err
}

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.28.0 // sqlc v1.29.0
// source: refresh_tokens.sql // source: refresh_tokens.sql
package data package data
@ -41,14 +41,17 @@ func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshToken
return i, err return i, err
} }
const deleteExpiredRefreshTokens = `-- name: DeleteExpiredRefreshTokens :exec const deleteExpiredRefreshTokens = `-- name: DeleteExpiredRefreshTokens :execrows
DELETE FROM refresh_tokens DELETE FROM refresh_tokens
WHERE expires_at < NOW() WHERE expires_at < NOW() OR revoked = TRUE
` `
func (q *Queries) DeleteExpiredRefreshTokens(ctx context.Context) error { func (q *Queries) DeleteExpiredRefreshTokens(ctx context.Context) (int64, error) {
_, err := q.db.Exec(ctx, deleteExpiredRefreshTokens) result, err := q.db.Exec(ctx, deleteExpiredRefreshTokens)
return err if err != nil {
return 0, err
}
return result.RowsAffected(), nil
} }
const getRefreshTokenByHash = `-- name: GetRefreshTokenByHash :one const getRefreshTokenByHash = `-- name: GetRefreshTokenByHash :one

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.28.0 // sqlc v1.29.0
// source: users.sql // source: users.sql
package data package data

View File

@ -9,6 +9,8 @@ import (
"git.umbrella.haus/ae/qnote/internal/data" "git.umbrella.haus/ae/qnote/internal/data"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/rs/zerolog/log"
) )
const ( const (
@ -38,8 +40,10 @@ type NoteStore interface {
// Chi HTTP router for notes related CRUD actions. // Chi HTTP router for notes related CRUD actions.
type notesResource struct { type notesResource struct {
Config SvcConfig Config SvcConfig
Notes NoteStore Notes NoteStore
RawQueries *data.Queries
DB *pgx.Conn
} }
func (rs notesResource) Routes() chi.Router { func (rs notesResource) Routes() chi.Router {
@ -250,6 +254,22 @@ func (rs *notesResource) CreateVersion(w http.ResponseWriter, r *http.Request) {
return return
} }
// Attempt to parse the expiration date from the title
expiresAt, err := parseTitleExpiration(req.Title)
if err != nil && err != ErrNoExpirationDateFound {
log.Error().Err(err).Msg("Failed parsing expiration date from note title")
}
tx, err := rs.DB.Begin(r.Context())
if err != nil {
log.Error().Err(err).Msg("Failed to begin transaction")
respondError(w, http.StatusInternalServerError, "Database error")
return
}
defer tx.Rollback(r.Context())
qtx := rs.RawQueries.WithTx(tx)
/* /*
The SQL query handles de-duplication checks and "intelligent" versioning increments, so we The SQL query handles de-duplication checks and "intelligent" versioning increments, so we
don't have to worry about them here (`latest_version` = highest version number that exists don't have to worry about them here (`latest_version` = highest version number that exists
@ -263,17 +283,37 @@ func (rs *notesResource) CreateVersion(w http.ResponseWriter, r *http.Request) {
- Sync `current_version` with `latest_version` - Sync `current_version` with `latest_version`
*/ */
err := rs.Notes.CreateNoteVersion(r.Context(), data.CreateNoteVersionParams{ if expiresAt != nil {
err = qtx.SetNoteExpiration(r.Context(), data.SetNoteExpirationParams{
ID: fullNote.NoteID,
UserID: fullNote.OwnerID,
ExpiresAt: expiresAt,
})
if err != nil {
log.Error().Err(err).Msg("Failed to set note expiration")
respondError(w, http.StatusInternalServerError, "Failed to set note expiration")
return
}
}
err = qtx.CreateNoteVersion(r.Context(), data.CreateNoteVersionParams{
NoteID: fullNote.NoteID, NoteID: fullNote.NoteID,
Title: *req.Title, Title: *req.Title,
Content: *req.Content, Content: *req.Content,
ContentHash: sha1ContentHash(*req.Title, *req.Content), ContentHash: sha1ContentHash(*req.Title, *req.Content),
}) })
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to create new note version")
respondError(w, http.StatusInternalServerError, "Failed to create note version") respondError(w, http.StatusInternalServerError, "Failed to create note version")
return return
} }
if err = tx.Commit(r.Context()); err != nil {
log.Error().Err(err).Msg("Failed to commit transaction")
respondError(w, http.StatusInternalServerError, "Database error")
return
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }

View File

@ -1,6 +1,7 @@
package service package service
import ( import (
"context"
"net/http" "net/http"
"time" "time"
@ -43,8 +44,10 @@ func Run(conn *pgx.Conn, q *data.Queries, config SvcConfig) error {
Tokens: q, Tokens: q,
} }
notesRouter := notesResource{ notesRouter := notesResource{
Config: config, Config: config,
Notes: q, Notes: q, // Wrapped (to be unit testable with mock DB)
RawQueries: q, // Passed separately to allow tx. usage
DB: conn,
} }
// Global middlewares // Global middlewares
@ -63,20 +66,38 @@ func Run(conn *pgx.Conn, q *data.Queries, config SvcConfig) error {
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(middleware.AllowContentType("application/json")) r.Use(middleware.AllowContentType("application/json"))
// Cleanup workers
scheduleTokenCleanup(context.Background(), q)
// Routes grouped by functionality (we must prefix the API routes with `/api` // Routes grouped by functionality (we must prefix the API routes with `/api`
// as the domain will be the same for the front and back ends) // as the domain will be the same for the front and back ends)
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
r.Mount("/auth", authRouter.Routes()) r.Mount("/auth", authRouter.Routes())
r.Mount("/notes", notesRouter.Routes()) r.Mount("/notes", notesRouter.Routes())
r.Get("/ping", ping) r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]string{
"message": "pong",
})
})
}) })
log.Info().Msg("Starting server on :8080") log.Info().Msg("Starting server on :8080")
return http.ListenAndServe(":8080", r) return http.ListenAndServe(":8080", r)
} }
func ping(w http.ResponseWriter, r *http.Request) { // Start worker that automatically cleans up the `notes` (cascading to `note_versions`) and
respondJSON(w, http.StatusOK, map[string]string{ // `refresh_tokens` tables from expired (or revoked) entries. The tasks run once during
"message": "pong", // initialization and then once an hour until the backend is shutdown.
}) func scheduleTokenCleanup(ctx context.Context, q *data.Queries) {
cleanupNotes(ctx, q)
cleanupRefreshTokens(ctx, q)
ticker := time.NewTicker(1 * time.Hour)
go func() {
for range ticker.C {
cleanupCtx := context.Background()
cleanupNotes(cleanupCtx, q)
cleanupRefreshTokens(cleanupCtx, q)
}
}()
} }

View File

@ -1,6 +1,7 @@
package service package service
import ( import (
"context"
"crypto/sha1" "crypto/sha1"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@ -12,8 +13,12 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time"
"unicode" "unicode"
"unicode/utf8" "unicode/utf8"
"git.umbrella.haus/ae/qnote/internal/data"
"github.com/rs/zerolog/log"
) )
const ( const (
@ -24,11 +29,25 @@ const (
minUsernameLength = 3 minUsernameLength = 3
maxUsernameLength = 20 maxUsernameLength = 20
maxFutureExpirationYears = 10
hibpAPI = "https://api.pwnedpasswords.com/range" // Doesn't require an API key hibpAPI = "https://api.pwnedpasswords.com/range" // Doesn't require an API key
) )
var ( var (
usernameRegex = regexp.MustCompile("^[a-z0-9_]+$") usernameRegex = regexp.MustCompile("^[a-z0-9_]+$")
// Format: @exp:2025-06-15 or @expires:2025-06-15
dateFormatRegex = regexp.MustCompile(`^@(?:exp|expires):(\d{4}-\d{2}-\d{2})`)
// Format: @exp:+7d or @expires:+7d (7 days from now),
// supports d (days), w (weeks), m (months), y (years)
relativeFormatRegex = regexp.MustCompile(`^@(?:exp|expires):\+(\d+)([dwmy])`)
ErrNoExpirationDateFound = errors.New("no expiration date found")
ErrInvalidExpirationDate = errors.New("invalid expiration date format")
ErrPastExpirationDate = errors.New("expiration date cannot be in the past")
ErrExpirationTooFar = fmt.Errorf("expiration date too far in the future (max. %d years)", maxFutureExpirationYears)
) )
func respondJSON(w http.ResponseWriter, status int, data any) { func respondJSON(w http.ResponseWriter, status int, data any) {
@ -186,3 +205,114 @@ func sha1ContentHash(title, content string) string {
return hashStr return hashStr
} }
func parseTitleExpiration(title *string) (*time.Time, error) {
// Absolute date format: '@exp:YYYY-MM-DD' (or '@expires:')
if match := dateFormatRegex.FindStringSubmatch(*title); match != nil {
dateStr := match[1]
expiresAt, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return nil, ErrInvalidExpirationDate
}
if err := validateExpirationDate(expiresAt); err != nil {
return nil, err
}
// Set midnight at the end of the specified day (+0000 UTC)
expiresAt = time.Date(expiresAt.Year(), expiresAt.Month(), expiresAt.Day(), 23, 59, 59, 0, time.UTC)
return &expiresAt, nil
}
if match := relativeFormatRegex.FindStringSubmatch(*title); match != nil {
amount := match[1]
unit := match[2]
var amountInt int
_, err := fmt.Sscanf(amount, "%d", &amountInt)
if err != nil || amountInt <= 0 {
return nil, ErrInvalidExpirationDate
}
now := time.Now()
var expiresAt time.Time
switch unit {
case "d":
expiresAt = now.AddDate(0, 0, amountInt)
case "w":
expiresAt = now.AddDate(0, 0, amountInt*7)
case "m":
expiresAt = now.AddDate(0, amountInt, 0)
case "y":
expiresAt = now.AddDate(amountInt, 0, 0)
default:
return nil, ErrInvalidExpirationDate
}
if err := validateExpirationDate(expiresAt); err != nil {
return nil, err
}
// Set midnight at the end of the specified day (+0000 UTC)
expiresAt = time.Date(expiresAt.Year(), expiresAt.Month(), expiresAt.Day(), 23, 59, 59, 0, time.UTC)
return &expiresAt, nil
}
return nil, ErrNoExpirationDateFound
}
func validateExpirationDate(date time.Time) error {
now := time.Now()
if date.Before(now) {
return ErrPastExpirationDate
}
maxDate := now.AddDate(maxFutureExpirationYears, 0, 0)
if date.After(maxDate) {
return ErrExpirationTooFar
}
return nil
}
func cleanupNotes(ctx context.Context, q *data.Queries) {
expiredNotes, err := q.ListExpiredNotes(ctx)
if err != nil {
log.Error().Err(err).Msg("Failed querying expired notes")
return
}
if len(expiredNotes) == 0 {
return
}
// Log what we're about to delete to be able to track potential bugs in the expiration implementation
for _, note := range expiredNotes {
log.Debug().Msgf("Deleting expired note: %s (ID: %s, UID: %s), expired at %s",
note.Title, note.NoteID, note.OwnerID, note.ExpiresAt.Format(time.RFC3339))
}
if err = q.DeleteExpiredNotes(ctx); err != nil {
log.Error().Err(err).Msg("Failed deleting expired notes")
return
}
log.Info().Msgf("Successfully deleted %d expired notes during scheduled cleanup", len(expiredNotes))
}
func cleanupRefreshTokens(ctx context.Context, q *data.Queries) {
rowsAffected, err := q.DeleteExpiredRefreshTokens(ctx)
if err != nil {
log.Error().Err(err).Msg("Failed cleaning up refresh tokens")
return
}
if rowsAffected > 0 {
log.Info().Msgf("Cleaned up %d expired/revoked refresh tokens during scheduled cleanup", rowsAffected)
}
}

View File

@ -1,6 +1,7 @@
package service package service
import ( import (
"errors"
"fmt" "fmt"
"math/rand" "math/rand"
"net/http" "net/http"
@ -290,6 +291,170 @@ func TestSHA1ContentHash(t *testing.T) {
} }
} }
func TestParseTitleAbsoluteExpiration(t *testing.T) {
threeDaysLater := time.Now().AddDate(0, 0, 3)
threeDaysInPast := time.Now().AddDate(0, 0, -3)
overMaxYearsLater := time.Now().AddDate(maxFutureExpirationYears+1, 0, 0)
tests := []struct {
name string
title string
expected *time.Time
err error
}{
{
name: "Valid absolute date",
title: fmt.Sprintf("@exp:%s Task", formatAbsDate(t, threeDaysLater)),
expected: timePtr(t, createEndOfDay(t, threeDaysLater)),
err: nil,
},
{
name: "Valid absolute date with expires keyword",
title: fmt.Sprintf("@expires:%s Task", formatAbsDate(t, threeDaysLater)),
expected: timePtr(t, createEndOfDay(t, threeDaysLater)),
err: nil,
},
{
name: "Absolute date in the past",
title: fmt.Sprintf("@exp:%s Task", formatAbsDate(t, threeDaysInPast)),
expected: nil,
err: ErrPastExpirationDate,
},
{
name: "Absolute date too far in the future",
title: fmt.Sprintf("@exp:%s Task", formatAbsDate(t, overMaxYearsLater)),
expected: nil,
err: ErrExpirationTooFar,
},
{
name: "Invalid absolute date format",
title: "@exp:2028-13-31 Task", // Invalid month
expected: nil,
err: ErrInvalidExpirationDate,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := parseTitleExpiration(&tc.title)
if tc.err != nil {
if !errors.Is(err, tc.err) {
t.Errorf("Expected error %s, got %s", tc.err, err)
}
} else if err != nil {
t.Errorf("Unexpected error: %s", err)
}
if tc.expected == nil && result != nil {
t.Errorf("Expected nil result, got %s", *result)
} else if tc.expected != nil && result != nil {
if !tc.expected.Equal(*result) {
t.Errorf("Expected %s, got %s", *tc.expected, *result)
}
}
})
}
}
func TestParseTitleRelativeExpiration(t *testing.T) {
threeDaysLater := time.Now().AddDate(0, 0, 3)
threeWeeksLater := time.Now().AddDate(0, 0, 3*7)
threeMonthsLater := time.Now().AddDate(0, 3, 0)
threeYearsLater := time.Now().AddDate(3, 0, 0)
tests := []struct {
name string
title string
expected *time.Time
err error
}{
{
name: "Valid relative date format with days",
title: "@exp:+3d Task",
expected: timePtr(t, createEndOfDay(t, threeDaysLater)),
err: nil,
},
{
name: "Valid relative date format with weeks",
title: "@exp:+3w Task",
expected: timePtr(t, createEndOfDay(t, threeWeeksLater)),
err: nil,
},
{
name: "Valid relative date format with months",
title: "@exp:+3m Task",
expected: timePtr(t, createEndOfDay(t, threeMonthsLater)),
err: nil,
},
{
name: "Valid relative date format with years",
title: "@exp:+3y Task",
expected: timePtr(t, createEndOfDay(t, threeYearsLater)),
err: nil,
},
{
name: "Invalid relative amount (zero)",
title: "@exp:+0d Task",
expected: nil,
err: ErrInvalidExpirationDate,
},
{
name: "Invalid relative amount (negative)",
title: "@exp:-1d Task",
expected: nil,
err: ErrNoExpirationDateFound, // Doesn't match either of the RegExs
},
{
name: "Invalid relative unit",
title: "@exp:+30a Task",
expected: nil,
err: ErrNoExpirationDateFound, // Doesn't match either of the RegExs
},
{
name: "Relative date too far in the future",
title: fmt.Sprintf("@exp:+%dy Task", maxFutureExpirationYears+1),
expected: nil,
err: ErrExpirationTooFar,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := parseTitleExpiration(&tc.title)
if tc.err != nil {
if !errors.Is(err, tc.err) {
t.Errorf("Expected error %s, got %s", tc.err, err)
}
} else if err != nil {
t.Errorf("Unexpected error: %s", err)
}
if tc.expected == nil && result != nil {
t.Errorf("Expected nil result, got %s", *result)
} else if tc.expected != nil && result != nil {
if !tc.expected.Equal(*result) {
t.Errorf("Expected %s, got %s", *tc.expected, *result)
}
}
})
}
}
func createEndOfDay(t *testing.T, tm time.Time) time.Time {
t.Helper()
return time.Date(tm.Year(), tm.Month(), tm.Day(), 23, 59, 59, 0, time.UTC)
}
func timePtr(t *testing.T, tm time.Time) *time.Time {
t.Helper()
return &tm
}
func formatAbsDate(t *testing.T, tm time.Time) string {
t.Helper()
return tm.Format("2006-01-02")
}
func genRandomString(t *testing.T, length int) string { func genRandomString(t *testing.T, length int) string {
t.Helper() t.Helper()
@ -299,5 +464,6 @@ func genRandomString(t *testing.T, length int) string {
for i := range b { for i := range b {
b[i] = charset[seededRand.Intn(len(charset))] b[i] = charset[seededRand.Intn(len(charset))]
} }
return string(b) return string(b)
} }

View File

@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS notes (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
current_version INT NOT NULL DEFAULT 1, -- active version (can be historical) current_version INT NOT NULL DEFAULT 1, -- active version (can be historical)
latest_version INT NOT NULL DEFAULT 1, -- highest version number latest_version INT NOT NULL DEFAULT 1, -- highest version number
expires_at TIMESTAMPTZ DEFAULT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
); );
@ -45,5 +46,6 @@ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens(expir
CREATE INDEX IF NOT EXISTS idx_notes_user_updated ON notes(user_id, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_notes_user_updated ON notes(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_notes_current_version ON notes(current_version); CREATE INDEX IF NOT EXISTS idx_notes_current_version ON notes(current_version);
CREATE INDEX IF NOT EXISTS idx_notes_expires_at ON notes(expires_at) WHERE expires_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_note_versions_content_hash ON note_versions(note_id, content_hash); CREATE INDEX IF NOT EXISTS idx_note_versions_content_hash ON note_versions(note_id, content_hash);

View File

@ -8,6 +8,7 @@ SELECT
n.id AS note_id, n.id AS note_id,
n.user_id AS owner_id, n.user_id AS owner_id,
nv.title, nv.title,
n.expires_at,
n.updated_at n.updated_at
FROM notes n FROM notes n
JOIN note_versions nv JOIN note_versions nv
@ -24,6 +25,7 @@ SELECT
nv.content, nv.content,
nv.version_number, nv.version_number,
nv.created_at AS version_created_at, nv.created_at AS version_created_at,
n.expires_at AS note_expires_at,
n.created_at AS note_created_at, n.created_at AS note_created_at,
n.updated_at AS note_updated_at n.updated_at AS note_updated_at
FROM notes n FROM notes n
@ -34,3 +36,24 @@ WHERE n.id = $1;
-- name: DeleteNote :exec -- name: DeleteNote :exec
DELETE FROM notes DELETE FROM notes
WHERE id = $1 AND user_id = $2; WHERE id = $1 AND user_id = $2;
-- name: SetNoteExpiration :exec
UPDATE notes
SET expires_at = $1, updated_at = NOW()
WHERE id = $2 AND user_id = $3;
-- name: ListExpiredNotes :many
SELECT
n.id AS note_id,
n.user_id AS owner_id,
nv.title,
n.expires_at
FROM notes n
JOIN note_versions nv
ON n.id = nv.note_id AND n.current_version = nv.version_number
WHERE n.expires_at <= NOW()
ORDER BY n.expires_at;
-- name: DeleteExpiredNotes :exec
DELETE FROM notes
WHERE expires_at < NOW();

View File

@ -20,6 +20,6 @@ UPDATE refresh_tokens
SET revoked = TRUE SET revoked = TRUE
WHERE user_id = $1; WHERE user_id = $1;
-- name: DeleteExpiredRefreshTokens :exec -- name: DeleteExpiredRefreshTokens :execrows
DELETE FROM refresh_tokens DELETE FROM refresh_tokens
WHERE expires_at < NOW(); WHERE expires_at < NOW() OR revoked = TRUE;