lots more stuff
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
# Backend Architecture
|
||||
|
||||
## Role of the backend
|
||||
|
||||
The backend is intentionally a **dumb encrypted sync target**. It does not understand the domain model, object types, or the tree structure. All business logic lives in the frontend. The backend only stores and retrieves opaque encrypted JSON blobs on behalf of an authenticated user.
|
||||
|
||||
This design is chosen to support end-to-end encryption (E2EE) and local-first sync in the future. The server never needs to decrypt object data.
|
||||
|
||||
## Data model
|
||||
|
||||
### `users`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ------------ | ------------ | ---------------------------------------- |
|
||||
| `userId` | VARCHAR(255) | Primary key. UUID or opaque identifier for the user. |
|
||||
| `name` | VARCHAR(255) | Display name for the user. |
|
||||
| `rootObjectId` | VARCHAR(255) | Points to the user's root object row. |
|
||||
|
||||
### `objects`
|
||||
|
||||
| Column | Type | Notes |
|
||||
| ------------ | ------------ | -------------------------------------------------- |
|
||||
| `objectId` | VARCHAR(255) | Primary key. UUID identifying the object. |
|
||||
| `userId` | VARCHAR(255) | Owner of the object. |
|
||||
| `objectData` | TEXT | Encrypted JSON blob (currently stored as plaintext during PoC). |
|
||||
|
||||
## API contract
|
||||
|
||||
### Users
|
||||
|
||||
#### `POST /users`
|
||||
|
||||
Create a new user and an empty root object.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": "string",
|
||||
"name": "string"
|
||||
}
|
||||
```
|
||||
|
||||
Routes use plural REST conventions (`/users`, `/objects`).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "created",
|
||||
"rootObjectId": "string"
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /users/:id`
|
||||
|
||||
Return the user profile **and all associated objects**.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": "string",
|
||||
"name": "string",
|
||||
"rootObjectId": "string",
|
||||
"objects": [
|
||||
{
|
||||
"objectId": "string",
|
||||
"objectData": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`objects` includes the root object and every object owned by the user. The client uses this to rebuild its local state in one request.
|
||||
|
||||
#### `PUT /users/:id`
|
||||
|
||||
Update the user's display name.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "string"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "updated"
|
||||
}
|
||||
```
|
||||
|
||||
> Note: `userId` is immutable. This endpoint only updates the display `name`.
|
||||
|
||||
#### `DELETE /users/:id`
|
||||
|
||||
Delete the user and all associated objects.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "deleted"
|
||||
}
|
||||
```
|
||||
|
||||
### Objects
|
||||
|
||||
#### `POST /objects`
|
||||
|
||||
Create a new object.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"objectId": "string",
|
||||
"userId": "string",
|
||||
"objectData": "string"
|
||||
}
|
||||
```
|
||||
|
||||
`objectId` is currently supplied by the client to support offline creation. In the future this may be server-generated as part of a more robust user creation flow.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "created",
|
||||
"objectId": "string"
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /objects/:id`
|
||||
|
||||
Fetch a single object by ID.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"objectId": "string",
|
||||
"userId": "string",
|
||||
"objectData": "string"
|
||||
}
|
||||
```
|
||||
|
||||
#### `PUT /objects/:id`
|
||||
|
||||
Update an object's encrypted data.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"objectData": "string"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "updated"
|
||||
}
|
||||
```
|
||||
|
||||
The client is responsible for updating parent `childIds` arrays when objects are moved or re-parented.
|
||||
|
||||
#### `DELETE /objects/:id`
|
||||
|
||||
Delete a single object.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "deleted"
|
||||
}
|
||||
```
|
||||
|
||||
The server does not cascade deletions because it cannot decrypt `objectData` to know the object's children. The client must walk the tree and delete children explicitly.
|
||||
|
||||
## Design decisions
|
||||
|
||||
- **Opaque object data**: The server stores `objectData` as an opaque string so it can remain encrypted. It never parses or validates the JSON shape.
|
||||
- **Client-owned tree logic**: Parent/child relationships are maintained inside the encrypted blobs by the frontend. The backend has no concept of roots, projects, tasks, or notes.
|
||||
- **Bulk user fetch**: `GET /users/:id` returns the entire object graph so the frontend can reconstruct state in one request instead of walking the tree with N+1 fetches.
|
||||
- **Plaintext PoC**: Encryption is planned but not implemented. `objectData` is currently plaintext JSON during early development.
|
||||
|
||||
## Known gaps and roadmap
|
||||
|
||||
| Priority | Item | Notes |
|
||||
| -------- | ---- | ----- |
|
||||
| High | Authentication & authorization | Every endpoint must verify the caller owns the requested user/object. Currently absent for PoC simplicity. |
|
||||
| High | Ownership checks | `GET /objects/:id`, `PUT /objects/:id`, and `DELETE /objects/:id` must reject access to objects owned by another user. |
|
||||
| Medium | Object versioning / `updatedAt` | Needed for safe multi-device sync and conflict detection. |
|
||||
| Medium | Multi-device conflict resolution | Last-write-wins is acceptable for PoC; eventually needs a real strategy (server wins, CRDTs, manual merge, etc.). |
|
||||
| Medium | Server-generated object IDs | Currently client-generated to support offline creation; may move to server-generated with auth flow. |
|
||||
| Low | Orphan garbage collection | Server cannot safely clean up orphans because it cannot decrypt blobs. Client-side `cleanupOrphans` on startup is planned. |
|
||||
| Low | Display name updates | `PUT /users/:id` updates `name`; `userId` remains immutable. |
|
||||
|
||||
Routes use plural REST conventions (`/users`, `/objects`) consistent with the implementation.
|
||||
|
||||
## Why not a single giant blob?
|
||||
|
||||
An alternative considered was storing the user's entire state as one encrypted JSON blob. Rejected because:
|
||||
|
||||
- Every small edit would require re-uploading the entire state.
|
||||
- Avoiding that requires a diff-chain format (operation logs, CRDTs, Merkle trees), which is significantly more complex than per-object blobs.
|
||||
|
||||
Per-object blobs strike a balance: the server stays dumb, edits are small, and the sync protocol remains simple.
|
||||
+1
-164
@@ -81,6 +81,7 @@ func createTables(db *sql.DB) error {
|
||||
"users": `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
userId VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
rootObjectId VARCHAR(255) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`,
|
||||
@@ -109,167 +110,3 @@ func createTables(db *sql.DB) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// User represents a row in the users table.
|
||||
type User struct {
|
||||
UserID string
|
||||
RootObjectID string
|
||||
}
|
||||
|
||||
// CreateUser inserts a new user into the users table.
|
||||
func CreateUser(userID, rootObjectID string) error {
|
||||
_, err := DB.Exec(
|
||||
"INSERT INTO users (userId, rootObjectId) VALUES (?, ?)",
|
||||
userID, rootObjectID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUser retrieves a user by ID.
|
||||
func GetUser(userID string) (User, error) {
|
||||
var user User
|
||||
err := DB.QueryRow(
|
||||
"SELECT userId, rootObjectId FROM users WHERE userId = ?",
|
||||
userID,
|
||||
).Scan(&user.UserID, &user.RootObjectID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return User{}, fmt.Errorf("user not found")
|
||||
}
|
||||
return User{}, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// UpdateUser updates the rootObjectId for a user.
|
||||
func UpdateUser(userID, rootObjectID string) error {
|
||||
result, err := DB.Exec(
|
||||
"UPDATE users SET rootObjectId = ? WHERE userId = ?",
|
||||
rootObjectID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check update result: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser removes a user by ID.
|
||||
func DeleteUser(userID string) error {
|
||||
result, err := DB.Exec("DELETE FROM users WHERE userId = ?", userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete user: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check delete result: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Object represents a row in the objects table.
|
||||
type Object struct {
|
||||
ObjectID string
|
||||
UserID string
|
||||
ObjectData string
|
||||
}
|
||||
|
||||
// CreateObject inserts a new object into the objects table.
|
||||
func CreateObject(objectID, userID, objectData string) error {
|
||||
_, err := DB.Exec(
|
||||
"INSERT INTO objects (objectId, userId, objectData) VALUES (?, ?, ?)",
|
||||
objectID, userID, objectData,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetObject retrieves an object by ID.
|
||||
func GetObject(objectID string) (Object, error) {
|
||||
var obj Object
|
||||
err := DB.QueryRow(
|
||||
"SELECT objectId, userId, objectData FROM objects WHERE objectId = ?",
|
||||
objectID,
|
||||
).Scan(&obj.ObjectID, &obj.UserID, &obj.ObjectData)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return Object{}, fmt.Errorf("object not found")
|
||||
}
|
||||
return Object{}, fmt.Errorf("failed to get object: %w", err)
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// GetObjectsByUser retrieves all objects belonging to a user.
|
||||
func GetObjectsByUser(userID string) ([]Object, error) {
|
||||
rows, err := DB.Query(
|
||||
"SELECT objectId, userId, objectData FROM objects WHERE userId = ?",
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query objects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var objects []Object
|
||||
for rows.Next() {
|
||||
var obj Object
|
||||
if err := rows.Scan(&obj.ObjectID, &obj.UserID, &obj.ObjectData); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan object: %w", err)
|
||||
}
|
||||
objects = append(objects, obj)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to iterate objects: %w", err)
|
||||
}
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
// UpdateObject updates the objectData for an object.
|
||||
func UpdateObject(objectID, objectData string) error {
|
||||
result, err := DB.Exec(
|
||||
"UPDATE objects SET objectData = ? WHERE objectId = ?",
|
||||
objectData, objectID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update object: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check update result: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("object not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteObject removes an object by ID.
|
||||
func DeleteObject(objectID string) error {
|
||||
result, err := DB.Exec("DELETE FROM objects WHERE objectId = ?", objectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete object: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check delete result: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("object not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
|
||||
@@ -36,6 +36,8 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
|
||||
-162
@@ -62,165 +62,3 @@ func main() {
|
||||
|
||||
router.Run() // listens on 0.0.0.0:8080 by default
|
||||
}
|
||||
|
||||
// createUserRequest is the expected body for POST /users.
|
||||
type createUserRequest struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
RootObjectID string `json:"rootObjectId" binding:"required"`
|
||||
}
|
||||
|
||||
func createUserHandler(c *gin.Context) {
|
||||
var req createUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := CreateUser(req.UserID, req.RootObjectID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"status": "created"})
|
||||
}
|
||||
|
||||
func getUserHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
user, err := GetUser(id)
|
||||
if err != nil {
|
||||
if err.Error() == "user not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
// updateUserRequest is the expected body for PUT /users/:id.
|
||||
type updateUserRequest struct {
|
||||
RootObjectID string `json:"rootObjectId" binding:"required"`
|
||||
}
|
||||
|
||||
func updateUserHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req updateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := UpdateUser(id, req.RootObjectID); err != nil {
|
||||
if err.Error() == "user not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
}
|
||||
|
||||
func deleteUserHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := DeleteUser(id); err != nil {
|
||||
if err.Error() == "user not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
// createObjectRequest is the expected body for POST /objects.
|
||||
type createObjectRequest struct {
|
||||
ObjectID string `json:"objectId" binding:"required"`
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
ObjectData string `json:"objectData" binding:"required"`
|
||||
}
|
||||
|
||||
func createObjectHandler(c *gin.Context) {
|
||||
var req createObjectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := CreateObject(req.ObjectID, req.UserID, req.ObjectData); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"status": "created"})
|
||||
}
|
||||
|
||||
func getObjectHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
obj, err := GetObject(id)
|
||||
if err != nil {
|
||||
if err.Error() == "object not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, obj)
|
||||
}
|
||||
|
||||
func getObjectsByUserHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
objects, err := GetObjectsByUser(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, objects)
|
||||
}
|
||||
|
||||
// updateObjectRequest is the expected body for PUT /objects/:id.
|
||||
type updateObjectRequest struct {
|
||||
ObjectData string `json:"objectData" binding:"required"`
|
||||
}
|
||||
|
||||
func updateObjectHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req updateObjectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := UpdateObject(id, req.ObjectData); err != nil {
|
||||
if err.Error() == "object not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
}
|
||||
|
||||
func deleteObjectHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := DeleteObject(id); err != nil {
|
||||
if err.Error() == "object not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Object represents a row in the objects table.
|
||||
type Object struct {
|
||||
ObjectID string
|
||||
UserID string
|
||||
ObjectData string
|
||||
}
|
||||
|
||||
// CreateObject inserts a new object into the objects table.
|
||||
func CreateObject(objectID, userID, objectData string) error {
|
||||
_, err := DB.Exec(
|
||||
"INSERT INTO objects (objectId, userId, objectData) VALUES (?, ?, ?)",
|
||||
objectID, userID, objectData,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetObject retrieves an object by ID.
|
||||
func GetObject(objectID string) (Object, error) {
|
||||
var obj Object
|
||||
err := DB.QueryRow(
|
||||
"SELECT objectId, userId, objectData FROM objects WHERE objectId = ?",
|
||||
objectID,
|
||||
).Scan(&obj.ObjectID, &obj.UserID, &obj.ObjectData)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return Object{}, fmt.Errorf("object not found")
|
||||
}
|
||||
return Object{}, fmt.Errorf("failed to get object: %w", err)
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// GetObjectsByUser retrieves all objects belonging to a user.
|
||||
func GetObjectsByUser(userID string) ([]Object, error) {
|
||||
rows, err := DB.Query(
|
||||
"SELECT objectId, userId, objectData FROM objects WHERE userId = ?",
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query objects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var objects []Object
|
||||
for rows.Next() {
|
||||
var obj Object
|
||||
if err := rows.Scan(&obj.ObjectID, &obj.UserID, &obj.ObjectData); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan object: %w", err)
|
||||
}
|
||||
objects = append(objects, obj)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to iterate objects: %w", err)
|
||||
}
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
// UpdateObject updates the objectData for an object.
|
||||
func UpdateObject(objectID, objectData string) error {
|
||||
result, err := DB.Exec(
|
||||
"UPDATE objects SET objectData = ? WHERE objectId = ?",
|
||||
objectData, objectID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update object: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check update result: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("object not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteObject removes an object by ID.
|
||||
func DeleteObject(objectID string) error {
|
||||
result, err := DB.Exec("DELETE FROM objects WHERE objectId = ?", objectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete object: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check delete result: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("object not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createObjectRequest is the expected body for POST /objects.
|
||||
type createObjectRequest struct {
|
||||
ObjectID string `json:"objectId"`
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
ObjectData string `json:"objectData" binding:"required"`
|
||||
}
|
||||
|
||||
func createObjectHandler(c *gin.Context) {
|
||||
var req createObjectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
objectID := req.ObjectID
|
||||
if objectID == "" {
|
||||
objectID = uuid.NewString()
|
||||
}
|
||||
|
||||
if err := CreateObject(objectID, req.UserID, req.ObjectData); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"status": "created", "objectId": objectID})
|
||||
}
|
||||
|
||||
func getObjectHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
obj, err := GetObject(id)
|
||||
if err != nil {
|
||||
if err.Error() == "object not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, obj)
|
||||
}
|
||||
|
||||
func getObjectsByUserHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
objects, err := GetObjectsByUser(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, objects)
|
||||
}
|
||||
|
||||
// updateObjectRequest is the expected body for PUT /objects/:id.
|
||||
type updateObjectRequest struct {
|
||||
ObjectData string `json:"objectData" binding:"required"`
|
||||
}
|
||||
|
||||
func updateObjectHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req updateObjectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := UpdateObject(id, req.ObjectData); err != nil {
|
||||
if err.Error() == "object not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
}
|
||||
|
||||
func deleteObjectHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := DeleteObject(id); err != nil {
|
||||
if err.Error() == "object not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
### Backend
|
||||
## UserTable
|
||||
1. name
|
||||
2. rootObjectId
|
||||
|
||||
# create
|
||||
POST /user
|
||||
id
|
||||
name
|
||||
create rootObject and attach objectId to user
|
||||
|
||||
# read
|
||||
GET /user/<id>
|
||||
fetch all objects associated with this userId (name)
|
||||
rootObjectId
|
||||
objects[{objectId, objectData}]
|
||||
|
||||
# update
|
||||
PUT /user/<id>
|
||||
name
|
||||
|
||||
# delete
|
||||
DELETE /user/<id>
|
||||
delete all associated objects
|
||||
delete user
|
||||
|
||||
## ObjectTable
|
||||
1. id
|
||||
2. userId
|
||||
3. data - encrypted JSON object
|
||||
|
||||
# create
|
||||
POST /object
|
||||
generate id
|
||||
userId
|
||||
data
|
||||
return id
|
||||
the client is responsible for updating the parent object's objectId array
|
||||
|
||||
# read
|
||||
GET /object/<id>
|
||||
data
|
||||
|
||||
# update
|
||||
PUT /object/<id>
|
||||
data
|
||||
the client is responsible for updating the parent object's objectId array
|
||||
|
||||
# delete
|
||||
DELETE /object/<id>
|
||||
the client is responsible for updating the parent object's objectId array
|
||||
|
||||
... that's it
|
||||
|
||||
|
||||
### Frontend
|
||||
|
||||
Objects are JSON objects
|
||||
Can be root idk
|
||||
|
||||
Hats/Views are scopes for other objects (like work, home, etc)
|
||||
|
||||
Projects are parent objects for kanbans/tasks???
|
||||
or not???
|
||||
maybe parents for everything?
|
||||
|
||||
All parent objects also have a default parent. for easy pick up and use.
|
||||
|
||||
children objects:
|
||||
tasks - checkbox object, can have notes
|
||||
notes -
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// User represents a row in the users table.
|
||||
type User struct {
|
||||
UserID string
|
||||
Name string
|
||||
RootObjectID string
|
||||
}
|
||||
|
||||
// CreateUser inserts a new user into the users table.
|
||||
func CreateUser(userID, name, rootObjectID string) error {
|
||||
_, err := DB.Exec(
|
||||
"INSERT INTO users (userId, name, rootObjectId) VALUES (?, ?, ?)",
|
||||
userID, name, rootObjectID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUser retrieves a user by ID.
|
||||
func GetUser(userID string) (User, error) {
|
||||
var user User
|
||||
err := DB.QueryRow(
|
||||
"SELECT userId, name, rootObjectId FROM users WHERE userId = ?",
|
||||
userID,
|
||||
).Scan(&user.UserID, &user.Name, &user.RootObjectID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return User{}, fmt.Errorf("user not found")
|
||||
}
|
||||
return User{}, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// UpdateUser updates the display name for a user.
|
||||
func UpdateUser(userID, name string) error {
|
||||
result, err := DB.Exec(
|
||||
"UPDATE users SET name = ? WHERE userId = ?",
|
||||
name, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check update result: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteObjectsByUser removes all objects belonging to a user.
|
||||
func DeleteObjectsByUser(userID string) error {
|
||||
_, err := DB.Exec("DELETE FROM objects WHERE userId = ?", userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete user objects: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser removes a user by ID and all associated objects.
|
||||
func DeleteUser(userID string) error {
|
||||
if err := DeleteObjectsByUser(userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := DB.Exec("DELETE FROM users WHERE userId = ?", userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete user: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check delete result: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createUserRequest is the expected body for POST /users.
|
||||
type createUserRequest struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
func createUserHandler(c *gin.Context) {
|
||||
var req createUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
rootObjectID := uuid.NewString()
|
||||
rootObjectData := `{"type":"root","childIds":[]}`
|
||||
|
||||
if err := CreateObject(rootObjectID, req.UserID, rootObjectData); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := CreateUser(req.UserID, req.Name, rootObjectID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"status": "created", "rootObjectId": rootObjectID})
|
||||
}
|
||||
|
||||
func getUserHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
user, err := GetUser(id)
|
||||
if err != nil {
|
||||
if err.Error() == "user not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
objects, err := GetObjectsByUser(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Return objects with camelCase keys to match the frontend contract.
|
||||
formattedObjects := make([]gin.H, 0, len(objects))
|
||||
for _, obj := range objects {
|
||||
formattedObjects = append(formattedObjects, gin.H{
|
||||
"objectId": obj.ObjectID,
|
||||
"userId": obj.UserID,
|
||||
"objectData": obj.ObjectData,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"userId": user.UserID,
|
||||
"name": user.Name,
|
||||
"rootObjectId": user.RootObjectID,
|
||||
"objects": formattedObjects,
|
||||
})
|
||||
}
|
||||
|
||||
// updateUserRequest is the expected body for PUT /users/:id.
|
||||
type updateUserRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
func updateUserHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req updateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := UpdateUser(id, req.Name); err != nil {
|
||||
if err.Error() == "user not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
}
|
||||
|
||||
func deleteUserHandler(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := DeleteUser(id); err != nil {
|
||||
if err.Error() == "user not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
Reference in New Issue
Block a user