Repository: ayinke-llc/malak (language: go)

Issue:
Create and store a new update
updates are always in draft mode unless sent. This should include the FE and backend work


Discussion / hints:
<p><a href="https://linear.app/ayinke-ventures/issue/MAL-9/api-to-create-a-new-update">MAL-9 API to create a new update</a></p>

Fix patch:
diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml
index 4342c29f..49bcaaa0 100644
--- a/.github/workflows/build-image.yml
+++ b/.github/workflows/build-image.yml
@@ -26,7 +26,6 @@ jobs:
           docker build \
             --build-arg VERSION=${GITHUB_SHA::8} \
             --build-arg COMMIT=${GITHUB_SHA} \
-            --build-arg DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
             -t ghcr.io/${GITHUB_REPOSITORY}:${{ steps.get_version.outputs.tag }} .
 
       - name: Push
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 764692c8..1beeec33 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -30,4 +30,11 @@ jobs:
         run: go build -o malak ./cmd
 
       - name: Run tests with coverage
-        run: go test -v -tags integration ./... -coverprofile=coverage.out -covermode=atomic
+        run: go test -v -tags integration ./... -coverprofile=coverage.txt
+
+      - name: Upload coverage reports to Codecov
+        uses: codecov/codecov-action@v4.0.1
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
+          files: ./coverage.txt
+          fail_ci_if_error: true
diff --git a/Dockerfile b/Dockerfile
index f947b211..c6eba83e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
-FROM golang:1.23 as build-env
+FROM golang:1.23 AS build-env
 WORKDIR /go/malak
 
-LABEL org.opencontainers.image.description "Open source Investors' relationship hub for Founders"
+LABEL org.opencontainers.image.description="Open source Investors' relationship hub for Founders"
 
 COPY ./go.mod /go/malak
 COPY ./go.sum /go/malak
@@ -14,10 +14,9 @@ COPY . .
 
 ARG VERSION=dev
 ARG COMMIT=none
-ARG DATE=unknown
 
 RUN CGO_ENABLED=0
-RUN go install -ldflags="-X main.Version=${VERSION} -X main.Commit=${COMMIT} -X main.Date=${DATE}" ./cmd/...
+RUN go install -ldflags="-X main.Version=${VERSION} -X main.Commit=${COMMIT}" ./cmd/...
 
 FROM gcr.io/distroless/base
 COPY --from=build-env /go/bin/cmd /
diff --git a/README.md b/README.md
index 8d861b62..ef210586 100644
--- a/README.md
+++ b/README.md
@@ -24,3 +24,5 @@ The Opensource Investors' relationship hub. [Learn more](https://malak.vc)
 ### Frontend
 
 - NextJS
+- [Tremor](https://tremor.so)
+- Tailwind CSS
diff --git a/docs/docs.go b/docs/docs.go
index a1d713e1..2a4bfbab 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -241,6 +241,116 @@ const docTemplate = `{
                     }
                 }
             }
+        },
+        "/workspaces/updates": {
+            "get": {
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "updates"
+                ],
+                "summary": "List updates",
+                "parameters": [
+                    {
+                        "type": "integer",
+                        "description": "Page to query data from. Defaults to 1",
+                        "name": "page",
+                        "in": "query"
+                    },
+                    {
+                        "type": "integer",
+                        "description": "Number to items to return. Defaults to 10 items",
+                        "name": "per_page",
+                        "in": "query"
+                    },
+                    {
+                        "type": "string",
+                        "description": "filter results by the status of the update.",
+                        "name": "status",
+                        "in": "query"
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/server.listUpdateResponse"
+                        }
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/server.APIStatus"
+                        }
+                    },
+                    "401": {
+                        "description": "Unauthorized",
+                        "schema": {
+                            "$ref": "#/definitions/server.APIStatus"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/server.APIStatus"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/server.APIStatus"
+                        }
+                    }
+                }
+            },
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "updates"
+                ],
+                "summary": "Create a new update",
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/server.createdUpdateResponse"
+                        }
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/server.APIStatus"
+                        }
+                    },
+                    "401": {
+                        "description": "Unauthorized",
+                        "schema": {
+                            "$ref": "#/definitions/server.APIStatus"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/server.APIStatus"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/server.APIStatus"
+                        }
+                    }
+                }
+            }
         }
     },
     "definitions": {
@@ -290,6 +400,9 @@ const docTemplate = `{
                 },
                 "updated_at": {
                     "type": "string"
+                },
+                "workspace_id": {
+                    "type": "string"
                 }
             }
         },
@@ -332,6 +445,62 @@ const docTemplate = `{
                 "RoleGuest"
             ]
         },
+        "malak.Update": {
+            "type": "object",
+            "properties": {
+                "content": {
+                    "type": "string"
+                },
+                "created_at": {
+                    "type": "string"
+                },
+                "created_by": {
+                    "type": "string"
+                },
+                "id": {
+                    "type": "string"
+                },
+                "metadata": {
+                    "$ref": "#/definitions/malak.UpdateMetadata"
+                },
+                "reference": {
+                    "type": "string"
+                },
+                "sent_at": {
+                    "type": "string"
+                },
+                "sent_by": {
+                    "type": "string"
+                },
+                "status": {
+                    "$ref": "#/definitions/malak.UpdateStatus"
+                },
+                "title": {
+                    "description": "Not persisted at all\nOnly calculated at runtime",
+                    "type": "string"
+                },
+                "updated_at": {
+                    "type": "string"
+                },
+                "workspace_id": {
+                    "type": "string"
+                }
+            }
+        },
+        "malak.UpdateMetadata": {
+            "type": "object"
+        },
+        "malak.UpdateStatus": {
+            "type": "string",
+            "enum": [
+                "draft",
+                "sent"
+            ],
+            "x-enum-varnames": [
+                "UpdateStatusDraft",
+                "UpdateStatusSent"
+            ]
+        },
         "malak.User": {
             "type": "object",
             "properties": {
@@ -470,6 +639,21 @@ const docTemplate = `{
                 }
             }
         },
+        "server.createdUpdateResponse": {
+            "type": "object",
+            "required": [
+                "message",
+                "update"
+            ],
+            "properties": {
+                "message": {
+                    "type": "string"
+                },
+                "update": {
+                    "$ref": "#/definitions/malak.Update"
+                }
+            }
+        },
         "server.createdUserResponse": {
             "type": "object",
             "required": [
@@ -495,6 +679,7 @@ const docTemplate = `{
         "server.fetchContactResponse": {
             "type": "object",
             "required": [
+                "contact",
                 "message"
             ],
             "properties": {
@@ -520,6 +705,58 @@ const docTemplate = `{
                     "$ref": "#/definitions/malak.Workspace"
                 }
             }
+        },
+        "server.listUpdateResponse": {
+            "type": "object",
+            "required": [
+                "message",
+                "meta",
+                "updates"
+            ],
+            "properties": {
+                "message": {
+                    "type": "string"
+                },
+                "meta": {
+                    "$ref": "#/definitions/server.meta"
+                },
+                "updates": {
+                    "type": "array",
+                    "items": {
+                        "$ref": "#/definitions/malak.Update"
+                    }
+                }
+            }
+        },
+        "server.meta": {
+            "type": "object",
+            "required": [
+                "paging"
+            ],
+            "properties": {
+                "paging": {
+                    "$ref": "#/definitions/server.pagingInfo"
+                }
+            }
+        },
+        "server.pagingInfo": {
+            "type": "object",
+            "required": [
+                "page",
+                "per_page",
+                "total"
+            ],
+            "properties": {
+                "page": {
+                    "type": "integer"
+                },
+                "per_page": {
+                    "type": "integer"
+                },
+                "total": {
+                    "type": "integer"
+                }
+            }
         }
     },
     "securityDefinitions": {
diff --git a/docs/swagger.json b/docs/swagger.json
index de77c2ca..1206ed00 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -237,6 +237,116 @@
                     }
                 }
             }
+        },
+        "/workspaces/updates": {
+            "get": {
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "updates"
+                ],
+                "summary": "List updates",
+                "parameters": [
+                    {
+                        "type": "integer",
+                        "description": "Page to query data from. Defaults to 1",
+                        "name": "page",
+                        "in": "query"
+                    },
+                    {
+                        "type": "integer",
+                        "description": "Number to items to return. Defaults to 10 items",
+                        "name": "per_page",
+                        "in": "query"
+                    },
+                    {
+                        "type": "string",
+                        "description": "filter results by the status of the update.",
+                        "name": "status",
+                        "in": "query"
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/server.listUpdateResponse"
+                        }
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/server.APIStatus"
+                        }
+                    },
+                    "401": {
+                        "description": "Unauthorized",
+                        "schema": {
+                            "$ref": "#/definitions/server.APIStatus"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/server.APIStatus"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/server.APIStatus"
+                        }
+                    }
+                }
+            },
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "updates"
+                ],
+                "summary": "Create a new update",
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/server.createdUpdateResponse"
+                        }
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/server.APIStatus"
+                        }
+                    },
+                    "401": {
+                        "description": "Unauthorized",
+                        "schema": {
+                            "$ref": "#/definitions/server.APIStatus"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/server.APIStatus"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/server.APIStatus"
+                        }
+                    }
+                }
+            }
         }
     },
     "definitions": {
@@ -286,6 +396,9 @@
                 },
                 "updated_at": {
                     "type": "string"
+                },
+                "workspace_id": {
+                    "type": "string"
                 }
             }
         },
@@ -328,6 +441,62 @@
                 "RoleGuest"
             ]
         },
+        "malak.Update": {
+            "type": "object",
+            "properties": {
+                "content": {
+                    "type": "string"
+                },
+                "created_at": {
+                    "type": "string"
+                },
+                "created_by": {
+                    "type": "string"
+                },
+                "id": {
+                    "type": "string"
+                },
+                "metadata": {
+                    "$ref": "#/definitions/malak.UpdateMetadata"
+                },
+                "reference": {
+                    "type": "string"
+                },
+                "sent_at": {
+                    "type": "string"
+                },
+                "sent_by": {
+                    "type": "string"
+                },
+                "status": {
+                    "$ref": "#/definitions/malak.UpdateStatus"
+                },
+                "title": {
+                    "description": "Not persisted at all\nOnly calculated at runtime",
+                    "type": "string"
+                },
+                "updated_at": {
+                    "type": "string"
+                },
+                "workspace_id": {
+                    "type": "string"
+                }
+            }
+        },
+        "malak.UpdateMetadata": {
+            "type": "object"
+        },
+        "malak.UpdateStatus": {
+            "type": "string",
+            "enum": [
+                "draft",
+                "sent"
+            ],
+            "x-enum-varnames": [
+                "UpdateStatusDraft",
+                "UpdateStatusSent"
+            ]
+        },
         "malak.User": {
             "type": "object",
             "properties": {
@@ -466,6 +635,21 @@
                 }
             }
         },
+        "server.createdUpdateResponse": {
+            "type": "object",
+            "required": [
+                "message",
+                "update"
+            ],
+            "properties": {
+                "message": {
+                    "type": "string"
+                },
+                "update": {
+                    "$ref": "#/definitions/malak.Update"
+                }
+            }
+        },
         "server.createdUserResponse": {
             "type": "object",
             "required": [
@@ -491,6 +675,7 @@
         "server.fetchContactResponse": {
             "type": "object",
             "required": [
+                "contact",
                 "message"
             ],
             "properties": {
@@ -516,6 +701,58 @@
                     "$ref": "#/definitions/malak.Workspace"
                 }
             }
+        },
+        "server.listUpdateResponse": {
+            "type": "object",
+            "required": [
+                "message",
+                "meta",
+                "updates"
+            ],
+            "properties": {
+                "message": {
+                    "type": "string"
+                },
+                "meta": {
+                    "$ref": "#/definitions/server.meta"
+                },
+                "updates": {
+                    "type": "array",
+                    "items": {
+                        "$ref": "#/definitions/malak.Update"
+                    }
+                }
+            }
+        },
+        "server.meta": {
+            "type": "object",
+            "required": [
+                "paging"
+            ],
+            "properties": {
+                "paging": {
+                    "$ref": "#/definitions/server.pagingInfo"
+                }
+            }
+        },
+        "server.pagingInfo": {
+            "type": "object",
+            "required": [
+                "page",
+                "per_page",
+                "total"
+            ],
+            "properties": {
+                "page": {
+                    "type": "integer"
+                },
+                "per_page": {
+                    "type": "integer"
+                },
+                "total": {
+                    "type": "integer"
+                }
+            }
         }
     },
     "securityDefinitions": {
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index eef97241..c397834d 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -35,6 +35,8 @@ definitions:
         type: string
       updated_at:
         type: string
+      workspace_id:
+        type: string
     type: object
   malak.CustomContactMetadata:
     additionalProperties:
@@ -64,6 +66,46 @@ definitions:
     - RoleBilling
     - RoleInvestor
     - RoleGuest
+  malak.Update:
+    properties:
+      content:
+        type: string
+      created_at:
+        type: string
+      created_by:
+        type: string
+      id:
+        type: string
+      metadata:
+        $ref: '#/definitions/malak.UpdateMetadata'
+      reference:
+        type: string
+      sent_at:
+        type: string
+      sent_by:
+        type: string
+      status:
+        $ref: '#/definitions/malak.UpdateStatus'
+      title:
+        description: |-
+          Not persisted at all
+          Only calculated at runtime
+        type: string
+      updated_at:
+        type: string
+      workspace_id:
+        type: string
+    type: object
+  malak.UpdateMetadata:
+    type: object
+  malak.UpdateStatus:
+    enum:
+    - draft
+    - sent
+    type: string
+    x-enum-varnames:
+    - UpdateStatusDraft
+    - UpdateStatusSent
   malak.User:
     properties:
       created_at:
@@ -161,6 +203,16 @@ definitions:
       name:
         type: string
     type: object
+  server.createdUpdateResponse:
+    properties:
+      message:
+        type: string
+      update:
+        $ref: '#/definitions/malak.Update'
+    required:
+    - message
+    - update
+    type: object
   server.createdUserResponse:
     properties:
       message:
@@ -183,6 +235,7 @@ definitions:
       message:
         type: string
     required:
+    - contact
     - message
     type: object
   server.fetchWorkspaceResponse:
@@ -195,6 +248,41 @@ definitions:
     - message
     - workspace
     type: object
+  server.listUpdateResponse:
+    properties:
+      message:
+        type: string
+      meta:
+        $ref: '#/definitions/server.meta'
+      updates:
+        items:
+          $ref: '#/definitions/malak.Update'
+        type: array
+    required:
+    - message
+    - meta
+    - updates
+    type: object
+  server.meta:
+    properties:
+      paging:
+        $ref: '#/definitions/server.pagingInfo'
+    required:
+    - paging
+    type: object
+  server.pagingInfo:
+    properties:
+      page:
+        type: integer
+      per_page:
+        type: integer
+      total:
+        type: integer
+    required:
+    - page
+    - per_page
+    - total
+    type: object
 host: localhost:5300
 info:
   contact:
@@ -350,6 +438,78 @@ paths:
       summary: Create a new workspace
       tags:
       - workspace
+  /workspaces/updates:
+    get:
+      consumes:
+      - application/json
+      parameters:
+      - description: Page to query data from. Defaults to 1
+        in: query
+        name: page
+        type: integer
+      - description: Number to items to return. Defaults to 10 items
+        in: query
+        name: per_page
+        type: integer
+      - description: filter results by the status of the update.
+        in: query
+        name: status
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/server.listUpdateResponse'
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/server.APIStatus'
+        "401":
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/server.APIStatus'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/server.APIStatus'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/server.APIStatus'
+      summary: List updates
+      tags:
+      - updates
+    post:
+      consumes:
+      - application/json
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/server.createdUpdateResponse'
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/server.APIStatus'
+        "401":
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/server.APIStatus'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/server.APIStatus'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/server.APIStatus'
+      summary: Create a new update
+      tags:
+      - updates
 schemes:
 - http
 securityDefinitions:
diff --git a/generate.go b/generate.go
index 980bf267..cdeb1868 100644
--- a/generate.go
+++ b/generate.go
@@ -12,3 +12,5 @@ package malak
 //go:generate mockgen -source=plan.go -destination=mocks/plan.go -package=malak_mocks
 //go:generate mockgen -source=workspace.go -destination=mocks/workspace.go -package=malak_mocks
 //go:generate mockgen -source=contact.go -destination=mocks/contact.go -package=malak_mocks
+//go:generate mockgen -source=update.go -destination=mocks/update.go -package=malak_mocks
+//go:generate mockgen -source=reference.go -destination=mocks/reference.go -package=malak_mocks
diff --git a/go.mod b/go.mod
index 5dadda97..4c723904 100644
--- a/go.mod
+++ b/go.mod
@@ -26,6 +26,7 @@ require (
 	github.com/uptrace/bun/dialect/pgdialect v1.2.1
 	github.com/uptrace/bun/driver/pgdriver v1.2.1
 	github.com/uptrace/bun/extra/bunotel v1.2.1
+	github.com/yuin/goldmark v1.4.13
 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0
 	go.opentelemetry.io/contrib/instrumentation/runtime v0.53.0
 	go.opentelemetry.io/otel v1.28.0
diff --git a/go.sum b/go.sum
index b4374940..a8aa7c61 100644
--- a/go.sum
+++ b/go.sum
@@ -308,6 +308,8 @@ github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgk
 github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
 github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
 go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
diff --git a/internal/datastore/postgres/migrations/20240820215002_initial_tables.up.sql b/internal/datastore/postgres/migrations/20240820215002_initial_tables.up.sql
index 45b80d35..1fd737eb 100644
--- a/internal/datastore/postgres/migrations/20240820215002_initial_tables.up.sql
+++ b/internal/datastore/postgres/migrations/20240820215002_initial_tables.up.sql
@@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS plans(
 
 CREATE TABLE IF NOT EXISTS users(
     id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
-    email VARCHAR (100) UNIQUE NOT NULL,
+    email VARCHAR (200) UNIQUE NOT NULL,
     full_name VARCHAR (100) NOT NULL,
     metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
 
diff --git a/internal/datastore/postgres/migrations/20240915144702_create_updates_table.down.sql b/internal/datastore/postgres/migrations/20240915144702_create_updates_table.down.sql
new file mode 100644
index 00000000..51f3a979
--- /dev/null
+++ b/internal/datastore/postgres/migrations/20240915144702_create_updates_table.down.sql
@@ -0,0 +1,4 @@
+DROP TABLE updates_schedules;
+DROP TABLE update_recipients;
+DROP TABLE update_links;
+DROP TABLE updates;
diff --git a/internal/datastore/postgres/migrations/20240915144702_create_updates_table.up.sql b/internal/datastore/postgres/migrations/20240915144702_create_updates_table.up.sql
new file mode 100644
index 00000000..12ac638d
--- /dev/null
+++ b/internal/datastore/postgres/migrations/20240915144702_create_updates_table.up.sql
@@ -0,0 +1,72 @@
+CREATE TYPE update_status AS ENUM ('draft', 'sent');
+
+CREATE TABLE updates(
+  id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+  workspace_id uuid NOT NULL REFERENCES workspaces(id),
+  status update_status NOT NULL DEFAULT 'draft',
+  reference VARCHAR (220) UNIQUE NOT NULL,
+  created_by uuid NOT NULL REFERENCES users(id),
+  sent_by uuid NULL REFERENCES users(id),
+  content TEXT NOT NULL DEFAULT '',
+  metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
+
+  sent_at    TIMESTAMP WITH TIME ZONE,
+  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  deleted_at TIMESTAMP WITH TIME ZONE
+);
+
+ALTER TABLE updates 
+  ADD CONSTRAINT updates_reference_check_key 
+  CHECK (reference ~ 'update_[a-zA-Z0-9._]+');
+
+CREATE TABLE update_links(
+  id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+  reference VARCHAR (220) UNIQUE NOT NULL,
+  update_id uuid NOT NULL REFERENCES updates(id),
+
+  expires_at TIMESTAMP WITH TIME ZONE,
+  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  deleted_at TIMESTAMP WITH TIME ZONE
+);
+
+ALTER TABLE update_links 
+  ADD CONSTRAINT update_links_reference_check_key 
+  CHECK (reference ~ 'link_[a-zA-Z0-9._]+');
+
+
+CREATE TABLE update_recipients(
+  id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+  reference VARCHAR (220) UNIQUE NOT NULL,
+  update_id uuid NOT NULL REFERENCES updates(id),
+  email VARCHAR (200) UNIQUE NOT NULL,
+
+  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  deleted_at TIMESTAMP WITH TIME ZONE
+);
+
+ALTER TABLE update_recipients 
+  ADD CONSTRAINT update_recipients_reference_check_key 
+  CHECK (reference ~ 'recipient_[a-zA-Z0-9._]+');
+
+
+CREATE TYPE update_schedule_status AS ENUM ('scheduled', 'cancelled', 'sent', 'failed');
+
+CREATE TABLE update_schedules(
+  id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+  reference VARCHAR (220) UNIQUE NOT NULL,
+  update_id uuid NOT NULL REFERENCES updates(id),
+  scheduled_by uuid NULL REFERENCES users(id),
+  status update_schedule_status NOT NULL DEFAULT 'scheduled',
+
+  send_at    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  deleted_at TIMESTAMP WITH TIME ZONE
+);
+
+ALTER TABLE update_schedules
+  ADD CONSTRAINT update_schedules_reference_check_key 
+  CHECK (reference ~ 'schedule_[a-zA-Z0-9._]+');
diff --git a/internal/datastore/postgres/update.go b/internal/datastore/postgres/update.go
new file mode 100644
index 00000000..e3a935d4
--- /dev/null
+++ b/internal/datastore/postgres/update.go
@@ -0,0 +1,55 @@
+package postgres
+
+import (
+	"context"
+	"database/sql"
+
+	"github.com/ayinke-llc/malak"
+	"github.com/uptrace/bun"
+)
+
+type updatesRepo struct {
+	inner *bun.DB
+}
+
+func NewUpdatesRepository(db *bun.DB) malak.UpdateRepository {
+	return &updatesRepo{
+		inner: db,
+	}
+}
+
+func (u *updatesRepo) Create(ctx context.Context,
+	update *malak.Update) error {
+
+	ctx, cancelFn := withContext(ctx)
+	defer cancelFn()
+
+	return u.inner.RunInTx(ctx, &sql.TxOptions{}, func(ctx context.Context, tx bun.Tx) error {
+
+		_, err := tx.NewInsert().
+			Model(update).
+			Exec(ctx)
+		return err
+	})
+}
+
+func (u *updatesRepo) List(ctx context.Context,
+	opts malak.ListUpdateOptions) ([]malak.Update, error) {
+
+	updates := make([]malak.Update, 0, opts.Paginator.PerPage)
+
+	q := u.inner.NewSelect().
+		Order("created_at DESC").
+		Where("workspace_id = ?", opts.WorkspaceID)
+
+	if opts.Status != malak.ListUpdateFilterStatusAll {
+		q = q.Where("status = ?", opts.Status)
+	}
+
+	err := q.Model(&updates).
+		Limit(int(opts.Paginator.PerPage)).
+		Offset(int(opts.Paginator.Offset())).
+		Scan(ctx)
+
+	return updates, err
+}
diff --git a/mocks/reference.go b/mocks/reference.go
new file mode 100644
index 00000000..5ff62251
--- /dev/null
+++ b/mocks/reference.go
@@ -0,0 +1,54 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: reference.go
+//
+// Generated by this command:
+//
+//	mockgen -source=reference.go -destination=mocks/reference.go -package=malak_mocks
+//
+
+// Package malak_mocks is a generated GoMock package.
+package malak_mocks
+
+import (
+	reflect "reflect"
+
+	malak "github.com/ayinke-llc/malak"
+	gomock "go.uber.org/mock/gomock"
+)
+
+// MockReferenceGeneratorOperation is a mock of ReferenceGeneratorOperation interface.
+type MockReferenceGeneratorOperation struct {
+	ctrl     *gomock.Controller
+	recorder *MockReferenceGeneratorOperationMockRecorder
+}
+
+// MockReferenceGeneratorOperationMockRecorder is the mock recorder for MockReferenceGeneratorOperation.
+type MockReferenceGeneratorOperationMockRecorder struct {
+	mock *MockReferenceGeneratorOperation
+}
+
+// NewMockReferenceGeneratorOperation creates a new mock instance.
+func NewMockReferenceGeneratorOperation(ctrl *gomock.Controller) *MockReferenceGeneratorOperation {
+	mock := &MockReferenceGeneratorOperation{ctrl: ctrl}
+	mock.recorder = &MockReferenceGeneratorOperationMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockReferenceGeneratorOperation) EXPECT() *MockReferenceGeneratorOperationMockRecorder {
+	return m.recorder
+}
+
+// Generate mocks base method.
+func (m *MockReferenceGeneratorOperation) Generate(arg0 malak.EntityType) malak.Reference {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Generate", arg0)
+	ret0, _ := ret[0].(malak.Reference)
+	return ret0
+}
+
+// Generate indicates an expected call of Generate.
+func (mr *MockReferenceGeneratorOperationMockRecorder) Generate(arg0 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Generate", reflect.TypeOf((*MockReferenceGeneratorOperation)(nil).Generate), arg0)
+}
diff --git a/mocks/update.go b/mocks/update.go
new file mode 100644
index 00000000..eca9734d
--- /dev/null
+++ b/mocks/update.go
@@ -0,0 +1,70 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: update.go
+//
+// Generated by this command:
+//
+//	mockgen -source=update.go -destination=mocks/update.go -package=malak_mocks
+//
+
+// Package malak_mocks is a generated GoMock package.
+package malak_mocks
+
+import (
+	context "context"
+	reflect "reflect"
+
+	malak "github.com/ayinke-llc/malak"
+	gomock "go.uber.org/mock/gomock"
+)
+
+// MockUpdateRepository is a mock of UpdateRepository interface.
+type MockUpdateRepository struct {
+	ctrl     *gomock.Controller
+	recorder *MockUpdateRepositoryMockRecorder
+}
+
+// MockUpdateRepositoryMockRecorder is the mock recorder for MockUpdateRepository.
+type MockUpdateRepositoryMockRecorder struct {
+	mock *MockUpdateRepository
+}
+
+// NewMockUpdateRepository creates a new mock instance.
+func NewMockUpdateRepository(ctrl *gomock.Controller) *MockUpdateRepository {
+	mock := &MockUpdateRepository{ctrl: ctrl}
+	mock.recorder = &MockUpdateRepositoryMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockUpdateRepository) EXPECT() *MockUpdateRepositoryMockRecorder {
+	return m.recorder
+}
+
+// Create mocks base method.
+func (m *MockUpdateRepository) Create(arg0 context.Context, arg1 *malak.Update) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Create", arg0, arg1)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Create indicates an expected call of Create.
+func (mr *MockUpdateRepositoryMockRecorder) Create(arg0, arg1 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockUpdateRepository)(nil).Create), arg0, arg1)
+}
+
+// List mocks base method.
+func (m *MockUpdateRepository) List(arg0 context.Context, arg1 malak.ListUpdateOptions) ([]malak.Update, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "List", arg0, arg1)
+	ret0, _ := ret[0].([]malak.Update)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// List indicates an expected call of List.
+func (mr *MockUpdateRepositoryMockRecorder) List(arg0, arg1 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockUpdateRepository)(nil).List), arg0, arg1)
+}
diff --git a/paginator.go b/paginator.go
new file mode 100644
index 00000000..eeca7663
--- /dev/null
+++ b/paginator.go
@@ -0,0 +1,73 @@
+package malak
+
+import (
+	"net/http"
+	"strconv"
+
+	"github.com/ayinke-llc/malak/internal/pkg/util"
+	"go.opentelemetry.io/otel/attribute"
+)
+
+const (
+	defaultNumOfItemPerPage = 8
+)
+
+type PaginatedResultMetadata struct {
+	Total int64
+}
+
+type Paginator struct {
+	PerPage int64
+	Page    int64
+}
+
+func (p Paginator) OTELAttributes() []attribute.KeyValue {
+	return []attribute.KeyValue{
+		attribute.Int64("per_page", p.PerPage),
+		attribute.Int64("page", p.Page),
+	}
+}
+
+func (p Paginator) Offset() int64 {
+	if p.Page <= 0 {
+		return 0
+	}
+
+	return (p.Page - 1) * p.PerPage
+}
+
+func PaginatorFromRequest(r *http.Request) Paginator {
+	defaultPage := 1
+
+	p := Paginator{
+		Page:    int64(defaultPage),
+		PerPage: defaultNumOfItemPerPage,
+	}
+
+	if !util.IsStringEmpty(r.URL.Query().Get("page")) {
+		currentPage := r.URL.Query().Get("page")
+
+		var err error
+
+		dd, err := strconv.Atoi(currentPage)
+		if err != nil || p.Page <= 0 {
+			return p
+		}
+
+		p.Page = int64(dd)
+	}
+
+	if !util.IsStringEmpty(r.URL.Query().Get("per_page")) {
+		perPage := r.URL.Query().Get("per_page")
+		var err error
+
+		dd, err := strconv.Atoi(perPage)
+		if err != nil || p.PerPage <= 0 {
+			return p
+		}
+
+		p.PerPage = int64(dd)
+	}
+
+	return p
+}
diff --git a/reference.go b/reference.go
index 2e894912..96732e2e 100644
--- a/reference.go
+++ b/reference.go
@@ -11,7 +11,11 @@ func GenerateReference(e EntityType) string {
 	return fmt.Sprintf("%s_%s", e.String(), shortid.MustGenerate())
 }
 
-// ENUM(workspace,invoice,team,invite,contact,deck,update)
+// ENUM(
+// workspace,invoice,
+// team,invite,contact,
+// deck,update,link,room,
+// recipient,schedule)
 type EntityType string
 
 type Reference string
@@ -29,5 +33,9 @@ func NewReferenceGenerator() *ReferenceGenerator {
 }
 
 func (r *ReferenceGenerator) Generate(e EntityType) Reference {
-	return Reference(fmt.Sprintf("%s_%s", e.String(), shortid.MustGenerate()))
+	return Reference(
+		fmt.Sprintf(
+			"%s_%s",
+			e.String(),
+			shortid.MustGenerate()))
 }
diff --git a/reference_enum.go b/reference_enum.go
index 18669690..56c62887 100644
--- a/reference_enum.go
+++ b/reference_enum.go
@@ -26,6 +26,14 @@ const (
 	EntityTypeDeck EntityType = "deck"
 	// EntityTypeUpdate is a EntityType of type update.
 	EntityTypeUpdate EntityType = "update"
+	// EntityTypeLink is a EntityType of type link.
+	EntityTypeLink EntityType = "link"
+	// EntityTypeRoom is a EntityType of type room.
+	EntityTypeRoom EntityType = "room"
+	// EntityTypeRecipient is a EntityType of type recipient.
+	EntityTypeRecipient EntityType = "recipient"
+	// EntityTypeSchedule is a EntityType of type schedule.
+	EntityTypeSchedule EntityType = "schedule"
 )
 
 var ErrInvalidEntityType = errors.New("not a valid EntityType")
@@ -50,6 +58,10 @@ var _EntityTypeValue = map[string]EntityType{
 	"contact":   EntityTypeContact,
 	"deck":      EntityTypeDeck,
 	"update":    EntityTypeUpdate,
+	"link":      EntityTypeLink,
+	"room":      EntityTypeRoom,
+	"recipient": EntityTypeRecipient,
+	"schedule":  EntityTypeSchedule,
 }
 
 // ParseEntityType attempts to convert a string to a EntityType.
diff --git a/server/auth.go b/server/auth.go
index 44da1445..8ea00b3f 100644
--- a/server/auth.go
+++ b/server/auth.go
@@ -175,12 +175,12 @@ func (a *authHandler) fetchCurrentUser(
 	logger.Debug("Fetching user profile")
 
 	var workspace *malak.Workspace = nil
-	if doesWorkspaceExistInContext(r.Context()) {
-		workspace = getWorkspaceFromContext(r.Context())
+	if doesWorkspaceExistInContext(ctx) {
+		workspace = getWorkspaceFromContext(ctx)
 	}
 
 	return createdUserResponse{
-		User:      util.DeRef(getUserFromContext(r.Context())),
+		User:      util.DeRef(getUserFromContext(ctx)),
 		Workspace: workspace,
 		APIStatus: newAPIStatus(http.StatusOK, "user data successfully retrieved"),
 	}, StatusFailed
diff --git a/server/handler.go b/server/handler.go
index daacdb5b..d382a053 100644
--- a/server/handler.go
+++ b/server/handler.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/ayinke-llc/malak/config"
 	"github.com/go-chi/render"
+	"go.opentelemetry.io/otel/attribute"
 	"go.opentelemetry.io/otel/codes"
 	"go.opentelemetry.io/otel/trace"
 	"go.uber.org/zap"
@@ -38,11 +39,17 @@ func WrapMalakHTTPHandler(
 		logger = logger.With(zap.String("request_id", rid))
 
 		if doesWorkspaceExistInContext(r.Context()) {
-			logger = logger.With(zap.String("workspace_id", getWorkspaceFromContext(r.Context()).ID.String()))
+			workspace := getWorkspaceFromContext(r.Context()).ID.String()
+			logger = logger.With(zap.String("workspace_id", workspace))
+			span.SetAttributes(
+				attribute.String("workspace_id", workspace))
 		}
 
 		if doesUserExistInContext(r.Context()) {
-			logger = logger.With(zap.String("user_id", getUserFromContext(r.Context()).ID.String()))
+			userID := getUserFromContext(r.Context()).ID.String()
+			logger = logger.With(zap.String("user_id", userID))
+			span.SetAttributes(
+				attribute.String("user_id", userID))
 		}
 
 		resp, status := handler(ctx, span, logger, w, r)
@@ -59,6 +66,9 @@ func WrapMalakHTTPHandler(
 			return
 		}
 
-		_ = render.Render(w, r, resp)
+		err := render.Render(w, r, resp)
+		if err != nil {
+			logger.Error("could not write http response", zap.Error(err))
+		}
 	}
 }
diff --git a/server/http.go b/server/http.go
index aefb1491..b1c53c52 100644
--- a/server/http.go
+++ b/server/http.go
@@ -67,6 +67,7 @@ func buildRoutes(
 	workspaceRepo := postgres.NewWorkspaceRepository(db)
 	planRepo := postgres.NewPlanRepository(db)
 	contactRepo := postgres.NewContactRepository(db)
+	updateRepo := postgres.NewUpdatesRepository(db)
 
 	router := chi.NewRouter()
 
@@ -93,6 +94,11 @@ func buildRoutes(
 		referenceGenerator: referenceGenerator,
 	}
 
+	updateHandler := &updatesHandler{
+		referenceGenerator: referenceGenerator,
+		updateRepo:         updateRepo,
+	}
+
 	router.Use(middleware.RequestID)
 	router.Use(writeRequestIDHeader)
 	router.Use(middleware.AllowContentType("application/json"))
@@ -116,6 +122,13 @@ func buildRoutes(
 			r.Use(requireAuthentication(logger, jwtTokenManager, cfg, userRepo, workspaceRepo))
 			r.Post("/",
 				WrapMalakHTTPHandler(logger, workspaceHandler.createWorkspace, cfg, "workspaces.new"))
+
+			r.Route("/updates", func(r chi.Router) {
+				r.Post("/",
+					WrapMalakHTTPHandler(logger, updateHandler.create, cfg, "updates.new"))
+				r.Get("/",
+					WrapMalakHTTPHandler(logger, updateHandler.list, cfg, "updates.list"))
+			})
 		})
 
 		r.Route("/contacts", func(r chi.Router) {
diff --git a/server/response.go b/server/response.go
index c98f245b..e828a92c 100644
--- a/server/response.go
+++ b/server/response.go
@@ -12,13 +12,13 @@ type GenericRequest struct{}
 func (g GenericRequest) Bind(_ *http.Request) error { return nil }
 
 type meta struct {
-	Paging pagingInfo `json:"paging"`
+	Paging pagingInfo `json:"paging,omitempty" validate:"required"`
 }
 
 type pagingInfo struct {
-	Total   int64 `json:"total"`
-	PerPage int64 `json:"per_page"`
-	Page    int64 `json:"page"`
+	Total   int64 `json:"total,omitempty" validate:"required"`
+	PerPage int64 `json:"per_page,omitempty" validate:"required"`
+	Page    int64 `json:"page,omitempty" validate:"required"`
 }
 
 type APIStatus struct {
@@ -55,6 +55,17 @@ type fetchWorkspaceResponse struct {
 }
 
 type fetchContactResponse struct {
-	Contact malak.Contact `json:"contact,omitempty"`
+	Contact malak.Contact `json:"contact,omitempty" validate:"required"`
+	APIStatus
+}
+
+type createdUpdateResponse struct {
+	Update malak.Update `json:"update,omitempty" validate:"required"`
+	APIStatus
+}
+
+type listUpdateResponse struct {
+	Updates []malak.Update `json:"updates,omitempty" validate:"required"`
+	Meta    meta           `json:"meta,omitempty" validate:"required"`
 	APIStatus
 }
diff --git a/server/update.go b/server/update.go
new file mode 100644
index 00000000..32e00cf5
--- /dev/null
+++ b/server/update.go
@@ -0,0 +1,132 @@
+package server
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/ayinke-llc/malak"
+	"github.com/ayinke-llc/malak/internal/pkg/util"
+	"github.com/go-chi/render"
+	"go.opentelemetry.io/otel/attribute"
+	"go.opentelemetry.io/otel/trace"
+	"go.uber.org/zap"
+)
+
+type updatesHandler struct {
+	referenceGenerator malak.ReferenceGeneratorOperation
+	updateRepo         malak.UpdateRepository
+}
+
+// @Summary Create a new update
+// @Tags updates
+// @Accept  json
+// @Produce  json
+// @Success 200 {object} createdUpdateResponse
+// @Failure 400 {object} APIStatus
+// @Failure 401 {object} APIStatus
+// @Failure 404 {object} APIStatus
+// @Failure 500 {object} APIStatus
+// @Router /workspaces/updates [post]
+func (u *updatesHandler) create(
+	ctx context.Context,
+	span trace.Span,
+	logger *zap.Logger,
+	w http.ResponseWriter,
+	r *http.Request) (render.Renderer, Status) {
+
+	logger.Debug("creating a new update")
+
+	user := getUserFromContext(r.Context())
+	workspace := getWorkspaceFromContext(r.Context())
+
+	update := &malak.Update{
+		WorkspaceID: workspace.ID,
+		CreatedBy:   user.ID,
+		Reference:   u.referenceGenerator.Generate(malak.EntityTypeUpdate),
+		Status:      malak.UpdateStatusDraft,
+		Content:     malak.UpdateContent(""),
+		Metadata:    malak.UpdateMetadata{},
+	}
+
+	if err := u.updateRepo.Create(ctx, update); err != nil {
+
+		logger.Error("could not create update",
+			zap.Error(err))
+
+		return newAPIStatus(
+			http.StatusInternalServerError,
+			"could not create a new update"), StatusFailed
+	}
+
+	span.AddEvent("workspace.new", trace.WithAttributes(
+		attribute.String("id", update.Reference.String())))
+
+	return createdUpdateResponse{
+		Update:    util.DeRef(update),
+		APIStatus: newAPIStatus(http.StatusCreated, "update successfully created"),
+	}, StatusSuccess
+}
+
+// @Summary List updates
+// @Tags updates
+// @Accept  json
+// @Produce  json
+// @Param page query int false "Page to query data from. Defaults to 1"
+// @Param per_page query int false "Number to items to return. Defaults to 10 items"
+// @Param status query string false "filter results by the status of the update."
+// @Success 200 {object} listUpdateResponse
+// @Failure 400 {object} APIStatus
+// @Failure 401 {object} APIStatus
+// @Failure 404 {object} APIStatus
+// @Failure 500 {object} APIStatus
+// @Router /workspaces/updates [get]
+func (u *updatesHandler) list(
+	ctx context.Context,
+	span trace.Span,
+	logger *zap.Logger,
+	w http.ResponseWriter,
+	r *http.Request) (render.Renderer, Status) {
+
+	logger.Debug("Listing updates")
+
+	workspace := getWorkspaceFromContext(r.Context())
+
+	filterStatus := malak.ListUpdateFilterStatus(r.URL.Query().Get("view"))
+
+	if !filterStatus.IsValid() {
+		filterStatus = malak.ListUpdateFilterStatusAll
+	}
+
+	opts := malak.ListUpdateOptions{
+		Status:      filterStatus,
+		Paginator:   malak.PaginatorFromRequest(r),
+		WorkspaceID: workspace.ID,
+	}
+
+	span.SetAttributes(
+		append(opts.Paginator.OTELAttributes(),
+			attribute.String("view",
+				filterStatus.String()))...)
+
+	updates, err := u.updateRepo.List(ctx, opts)
+	if err != nil {
+
+		logger.Error("could not list updates",
+			zap.Error(err))
+
+		return newAPIStatus(
+			http.StatusInternalServerError,
+			"could not list updates"), StatusFailed
+	}
+
+	return listUpdateResponse{
+		APIStatus: newAPIStatus(http.StatusCreated, "updates fetched"),
+		Updates:   updates,
+		Meta: meta{
+			Paging: pagingInfo{
+				PerPage: opts.Paginator.PerPage,
+				Page:    opts.Paginator.Page,
+			},
+		},
+	}, StatusSuccess
+}
diff --git a/update.go b/update.go
new file mode 100644
index 00000000..52d544e4
--- /dev/null
+++ b/update.go
@@ -0,0 +1,125 @@
+package malak
+
+import (
+	"context"
+	"encoding/json"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/uptrace/bun"
+)
+
+// ENUM(draft,sent)
+type UpdateStatus string
+
+// ENUM(draft,sent,all)
+type ListUpdateFilterStatus string
+
+// ENUM(scheduled,cancelled,sent,failed)
+type UpdateSendSchedule string
+
+type UpdateContent string
+
+type UpdateMetadata struct {
+}
+
+type Update struct {
+	ID          uuid.UUID     `bun:"type:uuid,default:uuid_generate_v4(),pk" json:"id,omitempty"`
+	WorkspaceID uuid.UUID     `json:"workspace_id,omitempty"`
+	Status      UpdateStatus  `json:"status,omitempty"`
+	Reference   Reference     `json:"reference,omitempty"`
+	CreatedBy   uuid.UUID     `json:"created_by,omitempty"`
+	SentBy      uuid.UUID     `json:"sent_by,omitempty" bun:",nullzero"`
+	Content     UpdateContent `json:"content,omitempty"`
+
+	// Not persisted at all
+	// Only calculated at runtime
+	Title string `json:"title" bun:"-"`
+
+	Metadata UpdateMetadata `json:"metadata,omitempty"`
+
+	SentAt    *time.Time `bun:",nullzero" json:"sent_at,omitempty"`
+	CreatedAt time.Time  `bun:",nullzero,notnull,default:current_timestamp" json:"created_at,omitempty"`
+	UpdatedAt time.Time  `bun:",nullzero,notnull,default:current_timestamp" json:"updated_at,omitempty"`
+	DeletedAt *time.Time `bun:",soft_delete,nullzero" json:"-,omitempty"`
+
+	bun.BaseModel `json:"-"`
+}
+
+func (u *Update) IsSent() bool { return u.Status == UpdateStatusSent }
+
+func (u *Update) MarshalJSON() ([]byte, error) {
+	type Alias Update
+
+	title, err := getFirstHeader(u.Content)
+	if err != nil {
+		return nil, err
+	}
+
+	return json.Marshal(&struct {
+		*Alias
+		Title string `json:"title"`
+	}{
+		Alias: (*Alias)(u),
+		Title: title,
+	})
+}
+
+type UpdateLink struct {
+	ID        uuid.UUID `bun:"type:uuid,default:uuid_generate_v4(),pk" json:"id,omitempty"`
+	Reference Reference `json:"reference,omitempty"`
+	UpdateID  uuid.UUID `json:"update_id,omitempty"`
+
+	// Sometimes, you want to share a link containing a specific update
+	// for a few minutes or seconds :)
+	ExpiresAt *time.Time `json:"expires_at,omitempty" bun:",nullzero,notnull,default:current_timestamp"`
+
+	CreatedAt     time.Time  `bun:",nullzero,notnull,default:current_timestamp" json:"created_at" `
+	UpdatedAt     time.Time  `bun:",nullzero,notnull,default:current_timestamp" json:"updated_at" `
+	DeletedAt     *time.Time `bun:",soft_delete,nullzero" json:"-,omitempty"`
+	bun.BaseModel `json:"-"`
+}
+
+type UpdateRecipient struct {
+	ID        uuid.UUID `bun:"type:uuid,default:uuid_generate_v4(),pk" json:"id,omitempty"`
+	Reference Reference `json:"reference,omitempty"`
+	UpdateID  uuid.UUID `json:"update_id,omitempty"`
+	Email     Email     `json:"email,omitempty"`
+
+	CreatedAt     time.Time `bun:",nullzero,notnull,default:current_timestamp" json:"created_at" `
+	UpdatedAt     time.Time `bun:",nullzero,notnull,default:current_timestamp" json:"updated_at" `
+	bun.BaseModel `json:"-"`
+}
+
+type UpdateSchedule struct {
+	ID          uuid.UUID          `bun:"type:uuid,default:uuid_generate_v4(),pk" json:"id,omitempty"`
+	Reference   Reference          `json:"reference,omitempty"`
+	UpdateID    uuid.UUID          `json:"update_id,omitempty"`
+	ScheduledBy uuid.UUID          `json:"scheduled_by,omitempty"`
+	Status      UpdateSendSchedule `json:"status,omitempty"`
+
+	// Time to send this update at?
+	SendAt        uuid.UUID `json:"send_at,omitempty"`
+	CreatedAt     time.Time `bun:",nullzero,notnull,default:current_timestamp" json:"created_at" `
+	UpdatedAt     time.Time `bun:",nullzero,notnull,default:current_timestamp" json:"updated_at" `
+	bun.BaseModel `json:"-"`
+}
+
+type FetchUpdateOptions struct {
+	Status    UpdateStatus
+	Reference Reference
+	ID        uuid.UUID
+}
+
+type ListUpdateOptions struct {
+	Paginator   Paginator
+	WorkspaceID uuid.UUID
+	Status      ListUpdateFilterStatus
+}
+
+type UpdateRepository interface {
+	Create(context.Context, *Update) error
+	// Update(context.Context, *Update) error
+	// Get(context.Context, FetchUpdateOptions) (*Update, error)
+	List(context.Context, ListUpdateOptions) ([]Update, error)
+}
diff --git a/update_enum.go b/update_enum.go
new file mode 100644
index 00000000..f3b9c7b3
--- /dev/null
+++ b/update_enum.go
@@ -0,0 +1,123 @@
+// Code generated by go-enum DO NOT EDIT.
+// Version:
+// Revision:
+// Build Date:
+// Built By:
+
+package malak
+
+import (
+	"errors"
+	"fmt"
+)
+
+const (
+	// ListUpdateFilterStatusDraft is a ListUpdateFilterStatus of type draft.
+	ListUpdateFilterStatusDraft ListUpdateFilterStatus = "draft"
+	// ListUpdateFilterStatusSent is a ListUpdateFilterStatus of type sent.
+	ListUpdateFilterStatusSent ListUpdateFilterStatus = "sent"
+	// ListUpdateFilterStatusAll is a ListUpdateFilterStatus of type all.
+	ListUpdateFilterStatusAll ListUpdateFilterStatus = "all"
+)
+
+var ErrInvalidListUpdateFilterStatus = errors.New("not a valid ListUpdateFilterStatus")
+
+// String implements the Stringer interface.
+func (x ListUpdateFilterStatus) String() string {
+	return string(x)
+}
+
+// IsValid provides a quick way to determine if the typed value is
+// part of the allowed enumerated values
+func (x ListUpdateFilterStatus) IsValid() bool {
+	_, err := ParseListUpdateFilterStatus(string(x))
+	return err == nil
+}
+
+var _ListUpdateFilterStatusValue = map[string]ListUpdateFilterStatus{
+	"draft": ListUpdateFilterStatusDraft,
+	"sent":  ListUpdateFilterStatusSent,
+	"all":   ListUpdateFilterStatusAll,
+}
+
+// ParseListUpdateFilterStatus attempts to convert a string to a ListUpdateFilterStatus.
+func ParseListUpdateFilterStatus(name string) (ListUpdateFilterStatus, error) {
+	if x, ok := _ListUpdateFilterStatusValue[name]; ok {
+		return x, nil
+	}
+	return ListUpdateFilterStatus(""), fmt.Errorf("%s is %w", name, ErrInvalidListUpdateFilterStatus)
+}
+
+const (
+	// UpdateSendScheduleScheduled is a UpdateSendSchedule of type scheduled.
+	UpdateSendScheduleScheduled UpdateSendSchedule = "scheduled"
+	// UpdateSendScheduleCancelled is a UpdateSendSchedule of type cancelled.
+	UpdateSendScheduleCancelled UpdateSendSchedule = "cancelled"
+	// UpdateSendScheduleSent is a UpdateSendSchedule of type sent.
+	UpdateSendScheduleSent UpdateSendSchedule = "sent"
+	// UpdateSendScheduleFailed is a UpdateSendSchedule of type failed.
+	UpdateSendScheduleFailed UpdateSendSchedule = "failed"
+)
+
+var ErrInvalidUpdateSendSchedule = errors.New("not a valid UpdateSendSchedule")
+
+// String implements the Stringer interface.
+func (x UpdateSendSchedule) String() string {
+	return string(x)
+}
+
+// IsValid provides a quick way to determine if the typed value is
+// part of the allowed enumerated values
+func (x UpdateSendSchedule) IsValid() bool {
+	_, err := ParseUpdateSendSchedule(string(x))
+	return err == nil
+}
+
+var _UpdateSendScheduleValue = map[string]UpdateSendSchedule{
+	"scheduled": UpdateSendScheduleScheduled,
+	"cancelled": UpdateSendScheduleCancelled,
+	"sent":      UpdateSendScheduleSent,
+	"failed":    UpdateSendScheduleFailed,
+}
+
+// ParseUpdateSendSchedule attempts to convert a string to a UpdateSendSchedule.
+func ParseUpdateSendSchedule(name string) (UpdateSendSchedule, error) {
+	if x, ok := _UpdateSendScheduleValue[name]; ok {
+		return x, nil
+	}
+	return UpdateSendSchedule(""), fmt.Errorf("%s is %w", name, ErrInvalidUpdateSendSchedule)
+}
+
+const (
+	// UpdateStatusDraft is a UpdateStatus of type draft.
+	UpdateStatusDraft UpdateStatus = "draft"
+	// UpdateStatusSent is a UpdateStatus of type sent.
+	UpdateStatusSent UpdateStatus = "sent"
+)
+
+var ErrInvalidUpdateStatus = errors.New("not a valid UpdateStatus")
+
+// String implements the Stringer interface.
+func (x UpdateStatus) String() string {
+	return string(x)
+}
+
+// IsValid provides a quick way to determine if the typed value is
+// part of the allowed enumerated values
+func (x UpdateStatus) IsValid() bool {
+	_, err := ParseUpdateStatus(string(x))
+	return err == nil
+}
+
+var _UpdateStatusValue = map[string]UpdateStatus{
+	"draft": UpdateStatusDraft,
+	"sent":  UpdateStatusSent,
+}
+
+// ParseUpdateStatus attempts to convert a string to a UpdateStatus.
+func ParseUpdateStatus(name string) (UpdateStatus, error) {
+	if x, ok := _UpdateStatusValue[name]; ok {
+		return x, nil
+	}
+	return UpdateStatus(""), fmt.Errorf("%s is %w", name, ErrInvalidUpdateStatus)
+}
diff --git a/update_title.go b/update_title.go
new file mode 100644
index 00000000..c6e77db3
--- /dev/null
+++ b/update_title.go
@@ -0,0 +1,40 @@
+package malak
+
+import (
+	"bytes"
+	"strings"
+
+	"github.com/yuin/goldmark"
+	"github.com/yuin/goldmark/ast"
+	"github.com/yuin/goldmark/text"
+)
+
+const ErrHeaderNotFound = malakError("could not find title of update")
+
+func getFirstHeader(markdown UpdateContent) (string, error) {
+	doc := goldmark.New().
+		Parser().
+		Parse(text.
+			NewReader([]byte(markdown)))
+
+	var title string
+
+	err := ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+		if entering && n.Kind() == ast.KindHeading {
+			heading := n.(*ast.Heading)
+			if heading.Level == 2 {
+				var buf bytes.Buffer
+				for c := n.FirstChild(); c != nil; c = c.NextSibling() {
+					if t, ok := c.(*ast.Text); ok {
+						buf.Write(t.Segment.Value([]byte(markdown)))
+					}
+				}
+				title = strings.TrimSpace(buf.String())
+				return ast.WalkStop, nil
+			}
+		}
+		return ast.WalkContinue, nil
+	})
+
+	return strings.TrimSpace(title), err
+}
diff --git a/web/ui/bun.lockb b/web/ui/bun.lockb
index 58168e05..5ad83857 100755
Binary files a/web/ui/bun.lockb and b/web/ui/bun.lockb differ
diff --git a/web/ui/package.json b/web/ui/package.json
index a7a4fd55..43bcac30 100644
--- a/web/ui/package.json
+++ b/web/ui/package.json
@@ -13,6 +13,7 @@
     "@hookform/resolvers": "^3.9.0",
     "@internationalized/date": "^3.5.5",
     "@nanostores/react": "ai/react",
+    "@radix-ui/react-avatar": "^1.1.0",
     "@radix-ui/react-dialog": "^1.1.1",
     "@radix-ui/react-dropdown-menu": "^2.1.1",
     "@radix-ui/react-label": "^2.1.0",
@@ -22,6 +23,7 @@
     "@radix-ui/react-select": "^2.1.1",
     "@radix-ui/react-separator": "^1.1.0",
     "@radix-ui/react-slot": "^1.1.0",
+    "@radix-ui/react-switch": "^1.1.0",
     "@react-aria/datepicker": "^3.11.1",
     "@react-oauth/google": "^0.12.1",
     "@react-stately/datepicker": "^3.10.1",
@@ -29,12 +31,14 @@
     "@tailwindcss/typography": "^0.5.15",
     "@tanstack/react-query": "^5.55.0",
     "@tanstack/react-query-devtools": "^5.55.0",
+    "@tanstack/react-table": "^8.20.5",
     "ai": "^3.3.36",
     "autoprefixer": "^10.4.20",
     "axios": "^1.7.7",
     "class-variance-authority": "^0.7.0",
     "clsx": "^2.1.1",
     "date-fns": "^3.6.0",
+    "email-validator": "^2.0.4",
     "highlight.js": "^11.10.0",
     "lowlight": "^3.1.0",
     "lucide-react": "^0.441.0",
@@ -44,6 +48,8 @@
     "react": "18.2.0",
     "react-day-picker": "^8.10.1",
     "react-dom": "18.2.0",
+    "react-loading-skeleton": "^3.4.0",
+    "react-select": "^5.8.0",
     "recharts": "^2.12.7",
     "sonner": "^1.5.0",
     "swagger-typescript-api": "^13.0.22",
diff --git a/web/ui/src/app/(main)/updates/new/page.tsx b/web/ui/src/app/(main)/updates/new/page.tsx
index 74b39a25..3b05764a 100644
--- a/web/ui/src/app/(main)/updates/new/page.tsx
+++ b/web/ui/src/app/(main)/updates/new/page.tsx
@@ -1,13 +1,54 @@
 "use client"
 
-import { Button } from "@/components/Button";
-import NovelEditor from "@/components/ui/updates/editor/editor"
+import { ServerAPIStatus } from "@/client/Api";
+import Skeleton from "@/components/ui/custom/loader/skeleton";
+import SendUpdateButton from "@/components/ui/updates/button/send";
+import SendTestButton from "@/components/ui/updates/button/send-test";
+import NovelEditor from "@/components/ui/updates/editor/editor";
+import client from "@/lib/client";
+import { CREATE_UPDATE } from "@/lib/query-constants";
+import { useMutation } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+import { useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
 
 export default function Page() {
 
+  const [isLoading, setIsLoading] = useState<boolean>(true)
+  const [reference, setReference] = useState<string | undefined>(undefined)
+
+  const router = useRouter()
+
+  const mutation = useMutation({
+    mutationKey: [CREATE_UPDATE],
+    mutationFn: () => client.workspaces.updatesCreate(),
+    onSuccess: ({ data }) => {
+      setReference(data.update.reference)
+      toast.success("Your update have been created now. As you type, we will sync and save your changes")
+    },
+    onError(err: AxiosError<ServerAPIStatus>) {
+      let msg = err.message
+      if (err.response !== undefined) {
+        msg = err.response.data.message
+      }
+      toast.error(msg)
+      router.push("/updates")
+    },
+    retry: false,
+    gcTime: Infinity,
+    onSettled: () => setIsLoading(false),
+  })
+
+  useEffect(() => {
+    mutation.mutate()
+  }, [])
+
   return (
-    <>
-      <div className="pt-6">
+    <div className="pt-6">
+      {isLoading ? (<div className="mt-10">
+        <Skeleton count={40} />
+      </div>) : (
         <section>
           <div className="sm:flex sm:items-center sm:justify-between">
             <div>
@@ -18,16 +59,17 @@ export default function Page() {
                 Sending a new update to your investors
               </p>
             </div>
-            <Button type="submit">
-              Send
-            </Button>
+            <div className="flex flex-wrap justify-center gap-1">
+              <SendTestButton />
+              <SendUpdateButton />
+            </div>
           </div>
 
           <div className="mt-5">
-            <NovelEditor />
+            <NovelEditor reference={reference} />
           </div>
         </section>
-      </div>
-    </>
+      )}
+    </div>
   )
 }
diff --git a/web/ui/src/app/(main)/updates/page.tsx b/web/ui/src/app/(main)/updates/page.tsx
index ad46d40c..50859922 100644
--- a/web/ui/src/app/(main)/updates/page.tsx
+++ b/web/ui/src/app/(main)/updates/page.tsx
@@ -1,6 +1,7 @@
 "use client"
 
 import { Button } from "@/components/Button"
+import ListUpdatesTable from "@/components/ui/updates/list/list"
 import { RiAddLine } from "@remixicon/react"
 import Link from "next/link"
 
@@ -33,6 +34,9 @@ export default function Page() {
             </div>
           </div>
         </section>
+        <div className="mt-10 sm:mt-4">
+          <ListUpdatesTable />
+        </div>
       </div>
     </>
   )
diff --git a/web/ui/src/components/Badge.tsx b/web/ui/src/components/Badge.tsx
index 52460be9..2c7d5de6 100644
--- a/web/ui/src/components/Badge.tsx
+++ b/web/ui/src/components/Badge.tsx
@@ -40,7 +40,7 @@ const badgeVariants = tv({
 
 interface BadgeProps
   extends React.ComponentPropsWithoutRef<"span">,
-    VariantProps<typeof badgeVariants> {}
+  VariantProps<typeof badgeVariants> { }
 
 const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
   ({ className, variant, ...props }: BadgeProps, forwardedRef) => {
diff --git a/web/ui/src/components/Button.tsx b/web/ui/src/components/Button.tsx
index 40929d53..b895b5b7 100644
--- a/web/ui/src/components/Button.tsx
+++ b/web/ui/src/components/Button.tsx
@@ -30,9 +30,9 @@ const buttonVariants = tv({
         // text color
         "text-white dark:text-gray-900",
         // background color
-        "bg-gray-900 dark:bg-gray-50",
+        "bg-indigo-600 dark:bg-indigo-400",
         // hover color
-        "hover:bg-gray-800 dark:hover:bg-gray-200",
+        "hover:bg-indigo-500 dark:hover:bg-indigo-600",
         // disabled
         "disabled:bg-gray-100 disabled:text-gray-400",
         "disabled:dark:bg-gray-800 disabled:dark:text-gray-600",
diff --git a/web/ui/src/components/Select.tsx b/web/ui/src/components/Select.tsx
index 6effc9d8..7e9ad816 100644
--- a/web/ui/src/components/Select.tsx
+++ b/web/ui/src/components/Select.tsx
@@ -1,5 +1,6 @@
-// Tremor Raw Select [v0.0.2]
+// Tremor Select [v0.0.3]
 
+import React from "react"
 import * as SelectPrimitives from "@radix-ui/react-select"
 import {
   RiArrowDownSLine,
@@ -7,7 +8,6 @@ import {
   RiCheckLine,
   RiExpandUpDownLine,
 } from "@remixicon/react"
-import React from "react"
 
 import { cx, focusInput, hasErrorInput } from "@/lib/utils"
 
@@ -57,6 +57,7 @@ const SelectTrigger = React.forwardRef<
         hasError ? hasErrorInput : "",
         className,
       )}
+      tremor-id="tremor-raw"
       {...props}
     >
       <span className="truncate">{children}</span>
@@ -161,7 +162,7 @@ const SelectContent = React.forwardRef<
           className={cx(
             "p-1",
             position === "popper" &&
-              "h-[var(--radix-select-trigger-height)] w-full min-w-[calc(var(--radix-select-trigger-width))]",
+            "h-[var(--radix-select-trigger-height)] w-full min-w-[calc(var(--radix-select-trigger-width))]",
           )}
         >
           {children}
@@ -230,6 +231,7 @@ const SelectItem = React.forwardRef<
 
 SelectItem.displayName = "SelectItem"
 
+
 const SelectSeparator = React.forwardRef<
   React.ElementRef<typeof SelectPrimitives.Separator>,
   React.ComponentPropsWithoutRef<typeof SelectPrimitives.Separator>
diff --git a/web/ui/src/components/Switch.tsx b/web/ui/src/components/Switch.tsx
new file mode 100644
index 00000000..c51372c0
--- /dev/null
+++ b/web/ui/src/components/Switch.tsx
@@ -0,0 +1,90 @@
+// Tremor Switch [v0.0.1]
+
+import React from "react"
+import * as SwitchPrimitives from "@radix-ui/react-switch"
+import { tv, VariantProps } from "tailwind-variants"
+
+import { cx, focusRing } from "@/lib/utils"
+
+const switchVariants = tv({
+  slots: {
+    root: [
+      // base
+      "group relative isolate inline-flex shrink-0 cursor-pointer items-center rounded-full p-0.5 shadow-inner outline-none ring-1 ring-inset transition-all",
+      "bg-gray-200 dark:bg-gray-950",
+      // ring color
+      "ring-black/5 dark:ring-gray-800",
+      // checked
+      "data-[state=checked]:bg-blue-500 data-[state=checked]:dark:bg-blue-500",
+      // disabled
+      "data-[disabled]:cursor-default",
+      // disabled checked
+      "data-[disabled]:data-[state=checked]:bg-blue-200",
+      "data-[disabled]:data-[state=checked]:ring-gray-300",
+      // disabled checked dark
+      "data-[disabled]:data-[state=checked]:dark:ring-gray-900",
+      "data-[disabled]:data-[state=checked]:dark:bg-blue-900",
+      // disabled unchecked
+      "data-[disabled]:data-[state=unchecked]:ring-gray-300",
+      "data-[disabled]:data-[state=unchecked]:bg-gray-100",
+      // disabled unchecked dark
+      "data-[disabled]:data-[state=unchecked]:dark:ring-gray-700",
+      "data-[disabled]:data-[state=unchecked]:dark:bg-gray-800",
+      focusRing,
+    ],
+    thumb: [
+      // base
+      "pointer-events-none relative inline-block transform appearance-none rounded-full border-none shadow-lg outline-none transition-all duration-150 ease-in-out focus:border-none focus:outline-none focus:outline-transparent",
+      // background color
+      "bg-white dark:bg-gray-50",
+      // disabled
+      "group-data-[disabled]:shadow-none",
+      "group-data-[disabled]:bg-gray-50 group-data-[disabled]:dark:bg-gray-500",
+    ],
+  },
+  variants: {
+    size: {
+      default: {
+        root: "h-5 w-9",
+        thumb:
+          "h-4 w-4 data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
+      },
+      small: {
+        root: "h-4 w-7",
+        thumb:
+          "h-3 w-3 data-[state=checked]:translate-x-3 data-[state=unchecked]:translate-x-0",
+      },
+    },
+  },
+  defaultVariants: {
+    size: "default",
+  },
+})
+
+interface SwitchProps
+  extends Omit<
+    React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>,
+    "asChild"
+  >,
+  VariantProps<typeof switchVariants> { }
+
+const Switch = React.forwardRef<
+  React.ElementRef<typeof SwitchPrimitives.Root>,
+  SwitchProps
+>(({ className, size, ...props }: SwitchProps, forwardedRef) => {
+  const { root, thumb } = switchVariants({ size })
+  return (
+    <SwitchPrimitives.Root
+      ref={forwardedRef}
+      className={cx(root(), className)}
+      tremor-id="tremor-raw"
+      {...props}
+    >
+      <SwitchPrimitives.Thumb className={cx(thumb())} />
+    </SwitchPrimitives.Root>
+  )
+})
+
+Switch.displayName = "Switch"
+
+export { Switch }
diff --git a/web/ui/src/components/providers/providers.tsx b/web/ui/src/components/providers/providers.tsx
index 64b078ab..05609997 100644
--- a/web/ui/src/components/providers/providers.tsx
+++ b/web/ui/src/components/providers/providers.tsx
@@ -5,8 +5,9 @@ import {
   QueryClientProvider,
 } from '@tanstack/react-query';
 import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
+import { ThemeProvider } from "@/components/providers/theme";
 import { GoogleOAuthProvider } from '@react-oauth/google';
-import { Toaster } from "sonner";
+import { Toaster } from "sonner"
 import { GOOGLE_CLIENT_ID } from "@/lib/config";
 import UserProvider from './user';
 
@@ -15,14 +16,23 @@ export default function Providers({ children }: { children: React.ReactNode }) {
   const queryClient = new QueryClient()
 
   return (
-    <QueryClientProvider client={queryClient}>
-      <ReactQueryDevtools initialIsOpen={false} />
-      <GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
-        <Toaster position="top-center" />
-        <UserProvider>
-          {children}
-        </UserProvider>
-      </GoogleOAuthProvider>
-    </QueryClientProvider >
+    <ThemeProvider
+      attribute="class"
+      defaultTheme="system"
+      enableSystem
+      disableTransitionOnChange
+    >
+      <QueryClientProvider client={queryClient}>
+        <ReactQueryDevtools initialIsOpen={false} />
+        <GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
+          <Toaster position="top-center" richColors />
+          <UserProvider>
+            {children}
+          </UserProvider>
+        </GoogleOAuthProvider>
+      </QueryClientProvider >
+    </ThemeProvider>
   )
 }
+
+
diff --git a/web/ui/src/components/ui/custom/README.md b/web/ui/src/components/ui/custom/README.md
new file mode 100644
index 00000000..1ca0edcf
--- /dev/null
+++ b/web/ui/src/components/ui/custom/README.md
@@ -0,0 +1,3 @@
+# Custom components
+
+These are custom components for Malak. More like design system components will come here in general. Also custom reusable wrappers over shadcn or something in general comes here
diff --git a/web/ui/src/components/ui/custom/avatar/avatar.tsx b/web/ui/src/components/ui/custom/avatar/avatar.tsx
new file mode 100644
index 00000000..80f5037a
--- /dev/null
+++ b/web/ui/src/components/ui/custom/avatar/avatar.tsx
@@ -0,0 +1,54 @@
+// https://ui.shadcx.com/docs/components/avatar
+//
+//
+
+"use client"
+
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+import * as React from "react"
+import { cx } from "@/lib/utils"
+
+const Avatar = React.forwardRef<
+  React.ElementRef<typeof AvatarPrimitive.Root>,
+  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
+>(({ className, ...props }, ref) => (
+  <AvatarPrimitive.Root
+    ref={ref}
+    className={cx(
+      "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
+      className
+    )}
+    {...props}
+  />
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+const AvatarImage = React.forwardRef<
+  React.ElementRef<typeof AvatarPrimitive.Image>,
+  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
+>(({ className, ...props }, ref) => (
+  <AvatarPrimitive.Image
+    ref={ref}
+    className={cx("aspect-square h-full w-full", className)}
+    {...props}
+  />
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+const AvatarFallback = React.forwardRef<
+  React.ElementRef<typeof AvatarPrimitive.Fallback>,
+  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
+>(({ className, ...props }, ref) => (
+  <AvatarPrimitive.Fallback
+    ref={ref}
+    className={cx(
+      "flex h-full w-full items-center justify-center rounded-full bg-muted",
+      className
+    )}
+    {...props}
+  />
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+export { Avatar, AvatarFallback, AvatarImage }
+
diff --git a/web/ui/src/components/ui/custom/loader/skeleton.tsx b/web/ui/src/components/ui/custom/loader/skeleton.tsx
new file mode 100644
index 00000000..0bd2aa35
--- /dev/null
+++ b/web/ui/src/components/ui/custom/loader/skeleton.tsx
@@ -0,0 +1,10 @@
+import BaseSkeleton from 'react-loading-skeleton';
+import 'react-loading-skeleton/dist/skeleton.css'
+
+const Skeleton = ({ count }: { count: number }) => {
+  return (
+    <BaseSkeleton count={count} />
+  )
+}
+
+export default Skeleton;
diff --git a/web/ui/src/components/ui/custom/select/select.tsx b/web/ui/src/components/ui/custom/select/select.tsx
new file mode 100644
index 00000000..7f456156
--- /dev/null
+++ b/web/ui/src/components/ui/custom/select/select.tsx
@@ -0,0 +1,46 @@
+import {
+  Select as BaseSelect,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/Select"
+import { Avatar, AvatarImage, AvatarFallback } from "../avatar/avatar"
+import { RemixiconComponentType } from "@remixicon/react"
+
+export type SelectProps = {
+  data: {
+    value: string
+    label: string
+    icon?: RemixiconComponentType
+    avatarURL?: string
+  }[]
+}
+
+export function Select({ data }: SelectProps) {
+  return (
+    <BaseSelect>
+      <SelectTrigger>
+        <SelectValue placeholder="Select" />
+      </SelectTrigger>
+      <SelectContent>
+        {data.map((item) => (
+          <SelectItem
+            key={item.value}
+            value={item.value}
+          >
+            <div className="flex items-center">
+              {item.icon && <item.icon className="mr-2 h-4 w-4" />}
+              {item.avatarURL && (
+                <Avatar className="mr-2 h-4 w-4" >
+                  <AvatarImage src={item.avatarURL} />
+                  <AvatarFallback>AY</AvatarFallback>
+                </Avatar>)}
+              {item.label}
+            </div>
+          </SelectItem>
+        ))}
+      </SelectContent>
+    </BaseSelect>
+  )
+}
diff --git a/web/ui/src/components/ui/custom/update/badge.tsx b/web/ui/src/components/ui/custom/update/badge.tsx
new file mode 100644
index 00000000..3ad501c7
--- /dev/null
+++ b/web/ui/src/components/ui/custom/update/badge.tsx
@@ -0,0 +1,11 @@
+import { Badge } from "@/components/Badge";
+
+const UpdateBadge = ({ status }: { status: string }) => {
+  return (
+    <Badge variant={"warning"} className="text-xs">
+      {status}
+    </Badge>
+  )
+}
+
+export default UpdateBadge;
diff --git a/web/ui/src/components/ui/updates/button/props.ts b/web/ui/src/components/ui/updates/button/props.ts
new file mode 100644
index 00000000..1b73a9a0
--- /dev/null
+++ b/web/ui/src/components/ui/updates/button/props.ts
@@ -0,0 +1,2 @@
+export type ButtonProps = {
+}
diff --git a/web/ui/src/components/ui/updates/button/send.tsx b/web/ui/src/components/ui/updates/button/send.tsx
new file mode 100644
index 00000000..76d8ecf9
--- /dev/null
+++ b/web/ui/src/components/ui/updates/button/send.tsx
@@ -0,0 +1,202 @@
+import { Button } from "@/components/Button";
+import {
+  Dialog,
+  DialogClose,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/Dialog";
+import { Label } from "@/components/Label";
+import { Switch } from "@/components/Switch";
+import { yupResolver } from "@hookform/resolvers/yup";
+import { RiMailSendLine } from "@remixicon/react";
+import * as EmailValidator from 'email-validator';
+import { Option } from "lucide-react";
+import { useEditor } from "novel";
+import { useState } from "react";
+import { SubmitHandler, useForm } from "react-hook-form";
+import CreatableSelect from 'react-select/creatable';
+import { toast } from "sonner";
+import * as yup from "yup";
+import { ButtonProps } from "./props";
+import { Select } from "../../custom/select/select";
+import client from "@/lib/client";
+import { ServerAPIStatus } from "@/client/Api";
+import { CREATE_CONTACT_MUTATION } from "@/lib/query-constants";
+import { useMutation } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+interface Option {
+  readonly label: string;
+  readonly value: string;
+}
+
+type SendUpdateInput = {
+  email: string
+  link?: boolean
+  recipients?: Option[]
+}
+
+const schema = yup
+  .object({
+    email: yup.string().min(5).max(50).required(),
+    link: yup.boolean().optional(),
+  })
+  .required()
+
+const SendUpdateButton = ({ }: ButtonProps) => {
+
+  const [loading, setLoading] = useState<boolean>(false)
+
+  const { editor } = useEditor()
+
+  const [options, setOptions] = useState<Option[]>([
+    { value: "oops", label: "oops" },
+    { value: "test", label: "test" }
+  ]);
+
+  const [value, setValue] = useState<Option[]>([]);
+
+  const contactMutation = useMutation({
+    mutationKey: [CREATE_CONTACT_MUTATION],
+    mutationFn: (data: { email: string }) => client.contacts.contactsCreate(data),
+    onSuccess: ({ data }) => {
+
+      toast.info(`${data.contact.email} has been added as a contact now`)
+
+      const newOption = {
+        value: data.contact.id,
+        label: data.contact.email
+      } as Option
+
+      setOptions((prev) => [...prev, newOption]);
+      setValue((prev) => [...prev, newOption]);
+    },
+    onError(err: AxiosError<ServerAPIStatus>) {
+      let msg = err.message
+      if (err.response !== undefined) {
+        msg = err.response.data.message
+      }
+      toast.error(msg)
+    },
+    retry: false,
+    gcTime: Infinity,
+    onSettled: () => setLoading(false),
+  })
+
+  const createNewContact = (inputValue: string) => {
+    setLoading(true);
+
+    if (!EmailValidator.validate(inputValue)) {
+      toast.error("you can only add an email address as a new recipient")
+      setLoading(false)
+      return
+    }
+
+    contactMutation.mutate({ email: inputValue })
+  };
+
+  const {
+    register,
+    formState: { errors },
+    handleSubmit,
+  } = useForm({
+    resolver: yupResolver(schema),
+  })
+
+  const onSubmit: SubmitHandler<SendUpdateInput> = (data) => {
+    setLoading(true)
+    console.log(data)
+  }
+
+  return (
+    <>
+      <div className="flex justify-center">
+        <Dialog>
+          <DialogTrigger asChild>
+            <Button type="submit"
+              size="lg"
+              variant="primary"
+              className="gap-1">
+              <RiMailSendLine size={18} />
+              Send
+            </Button>
+          </DialogTrigger>
+          <DialogContent className="sm:max-w-lg">
+            <form onSubmit={handleSubmit(onSubmit)}>
+              <DialogHeader>
+                <DialogTitle>
+                  Send this update
+                </DialogTitle>
+                <DialogDescription className="mt-1 text-sm leading-6">
+                  An email will be sent to all selected contacts immediately.
+                  Please re-verify your content is ready and good to go
+                </DialogDescription>
+
+                <div className="mt-4">
+                  <CreatableSelect
+                    isMulti
+                    isClearable
+                    isDisabled={loading}
+                    isLoading={loading}
+                    onChange={(newValue) => {
+                      setValue(newValue)
+                    }}
+                    onCreateOption={createNewContact}
+                    options={options}
+                    value={value}
+                  />
+                </div>
+
+                <div className="mt-4">
+                  <Label htmlFor="select-author" className="font-medium">
+                    Select Author
+                  </Label>
+                  <Select data={[
+                    {
+                      label: "Lanre Adelowo",
+                      value: "lanre"
+                    },
+                    {
+                      label: "Ayinke",
+                      value: "ayinke"
+                    }
+                  ]} />
+                </div>
+
+                <div className="mt-4">
+                  <Switch disabled id="r3" {...register("link")} />
+                  <Label disabled htmlFor="r3">
+                    Coming soon. Generate a public viewable link for this update
+                  </Label>
+                </div>
+              </DialogHeader>
+              <DialogFooter className="mt-6">
+                <DialogClose asChild>
+                  <Button
+                    type={"button"}
+                    className="mt-2 w-full sm:mt-0 sm:w-fit"
+                    variant="secondary"
+                    isLoading={loading}
+                  >
+                    Cancel
+                  </Button>
+                </DialogClose>
+                <Button type="submit"
+                  className="w-full sm:w-fit"
+                  isLoading={loading}>
+                  Send
+                </Button>
+              </DialogFooter>
+            </form>
+          </DialogContent>
+        </Dialog>
+      </div>
+    </>
+  )
+}
+
+export default SendUpdateButton;
diff --git a/web/ui/src/components/ui/updates/editor/default-value.ts b/web/ui/src/components/ui/updates/editor/default-value.ts
new file mode 100644
index 00000000..cc7d7e85
--- /dev/null
+++ b/web/ui/src/components/ui/updates/editor/default-value.ts
@@ -0,0 +1,12 @@
+import { format } from 'date-fns';
+
+export const defaultEditorContent = {
+  type: "doc",
+  content: [
+    {
+      type: "heading",
+      attrs: { level: 2 },
+      content: [{ type: "text", text: `${format(new Date(), "EEEE, MMMM do, yyyy")} Update` }],
+    }
+  ]
+};
diff --git a/web/ui/src/components/ui/updates/editor/editor.tsx b/web/ui/src/components/ui/updates/editor/editor.tsx
index 74622760..b1d1c46a 100644
--- a/web/ui/src/components/ui/updates/editor/editor.tsx
+++ b/web/ui/src/components/ui/updates/editor/editor.tsx
@@ -7,11 +7,10 @@ import {
   EditorCommandList,
   EditorContent,
   type EditorInstance,
-  EditorRoot,
-  type JSONContent,
+  EditorRoot
 } from "novel";
 import { ImageResizer, handleCommandNavigation } from "novel/extensions";
-import { useEffect, useState } from "react";
+import { useState } from "react";
 import { useDebouncedCallback } from "use-debounce";
 import { defaultExtensions } from "./extensions";
 import { ColorSelector } from "./selectors/color-selector";
@@ -24,15 +23,20 @@ import GenerativeMenuSwitch from "./generative/generative-menu-switch";
 import { uploadFn } from "./image-upload";
 import { TextButtons } from "./selectors/text-buttons";
 import { slashCommand, suggestionItems } from "./slash-command";
+import { defaultEditorContent } from "./default-value";
+import { toast } from "sonner";
 
-const hljs = require('highlight.js');
+export type EditorProps = {
+  reference: string | undefined
+}
 
-const extensions = [...defaultExtensions, slashCommand];
+const NovelEditor = ({ reference }: EditorProps) => {
 
-const NovelEditor = () => {
+  if (reference === undefined || reference === "") {
+    return null
+  }
 
-  const [initialContent, setInitialContent] = useState<null | JSONContent>(null);
-  const [saveStatus, setSaveStatus] = useState("Saved");
+  const [saveStatus, setSaveStatus] = useState<"Saved" | "Unsaved">("Saved");
   const [charsCount, setCharsCount] = useState();
 
   const [openNode, setOpenNode] = useState(false);
@@ -40,23 +44,35 @@ const NovelEditor = () => {
   const [openLink, setOpenLink] = useState(false);
   const [openAI, setOpenAI] = useState(false);
 
-  //Apply Codeblock Highlighting on the HTML from editor.getHTML()
-  const highlightCodeblocks = (content: string) => {
-    const doc = new DOMParser().parseFromString(content, 'text/html');
-    doc.querySelectorAll('pre code').forEach((el) => {
-      // @ts-ignore
-      // https://highlightjs.readthedocs.io/en/latest/api.html?highlight=highlightElement#highlightelement
-      hljs.highlightElement(el);
-    });
-    return new XMLSerializer().serializeToString(doc);
-  };
+  // Let the user actually type a bunch of stuff before we create the update
+  const [isCreated, setIsCreated] = useState<boolean>(false)
 
   const debouncedUpdates = useDebouncedCallback(async (editor: EditorInstance) => {
-    const json = editor.getJSON();
+
+    const updatedJSON = editor.getJSON();
+
+    const title = updatedJSON.content?.at(0)
+
+    if (!title) {
+      toast.error("updates must include a title. Please add a title using a heading")
+      return
+    }
+
+    if (title.type !== "heading") {
+      toast.error("Your heading must be the first item in the editor. It serves as the title of your update.")
+      return
+    }
+
+    const content = title.content?.at(0)
+
+    if (content?.type != "text" && content?.text?.trim().length === 0) {
+      toast.error("Title can only include text and must not be empty")
+      return
+    }
+
+    console.log(editor.storage.markdown.getMarkdown())
+
     setCharsCount(editor.storage.characterCount.words());
-    window.localStorage.setItem("html-content", highlightCodeblocks(editor.getHTML()));
-    window.localStorage.setItem("novel-content", JSON.stringify(json));
-    window.localStorage.setItem("markdown", editor.storage.markdown.getMarkdown());
     setSaveStatus("Saved");
   }, 500);
 
@@ -71,8 +87,8 @@ const NovelEditor = () => {
       </div>
       <EditorRoot>
         <EditorContent
-          initialContent={initialContent as JSONContent}
-          extensions={extensions}
+          initialContent={defaultEditorContent}
+          extensions={[...defaultExtensions, slashCommand]}
           className="relative min-h-[500px] w-full max-w-screen-lg border-muted bg-background sm:mb-[calc(20vh)] sm:rounded-lg sm:border sm:shadow-lg"
           editorProps={{
             handleDOMEvents: {
diff --git a/web/ui/src/components/ui/updates/list/list.tsx b/web/ui/src/components/ui/updates/list/list.tsx
new file mode 100644
index 00000000..0294fd4c
--- /dev/null
+++ b/web/ui/src/components/ui/updates/list/list.tsx
@@ -0,0 +1,70 @@
+import { MalakUpdate } from "@/client/Api"
+import client from "@/lib/client"
+import { LIST_UPDATES } from "@/lib/query-constants"
+import { useInfiniteQuery } from "@tanstack/react-query"
+import SingleUpdate from "./single"
+import { Button } from "@/components/Button"
+import { toast } from "sonner"
+import Skeleton from "../../custom/loader/skeleton"
+
+export type UpdateListTableProps = {
+  data: MalakUpdate[]
+}
+
+const ListUpdatesTable = () => {
+
+  const {
+    data,
+    error,
+    fetchNextPage,
+    hasNextPage,
+    isFetching,
+    isFetchingNextPage
+  } = useInfiniteQuery({
+    queryKey: [LIST_UPDATES],
+    queryFn: ({ pageParam }) => {
+      return client.workspaces.updatesList({
+        page: pageParam,
+      })
+    },
+    getNextPageParam: (lastPage) => {
+      if (lastPage.data?.updates == undefined) {
+        return undefined
+      }
+
+      return (lastPage.data.meta.paging?.page as number) + 1
+    },
+    retry: false,
+    initialPageParam: 1,
+  })
+
+  if (error) {
+    toast.error(error.message)
+  }
+
+  return (
+    <div>
+      {isFetching ?
+        <Skeleton count={30} /> : (
+          data?.pages?.map((value) => {
+            return value?.data?.updates?.map((update, idx) => {
+              return <SingleUpdate {...update} key={idx} />
+            })
+          }))}
+
+      {isFetchingNextPage && <Skeleton count={30} />}
+
+      {hasNextPage && (
+        <div className="mt-5">
+          <Button
+            variant="secondary"
+            onClick={() => fetchNextPage()}>
+            Load more
+          </Button>
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default ListUpdatesTable;
diff --git a/web/ui/src/components/ui/updates/list/single.tsx b/web/ui/src/components/ui/updates/list/single.tsx
new file mode 100644
index 00000000..39a8e142
--- /dev/null
+++ b/web/ui/src/components/ui/updates/list/single.tsx
@@ -0,0 +1,33 @@
+import { MalakUpdate } from "@/client/Api";
+import { Badge } from "@/components/Badge";
+import { Button } from "@/components/Button";
+import { Divider } from "@/components/Divider";
+import { RiMoreLine, RiPushpinLine } from "@remixicon/react";
+import UpdateBadge from "../../custom/update/badge";
+
+const SingleUpdate = (update: MalakUpdate) => {
+  return (
+    <>
+      <div key={update.id} className="flex items-center justify-between p-2 hover:bg-accent rounded-lg transition-colors">
+        <div className="flex flex-col space-y-1">
+          <div className="flex items-center space-x-2">
+            <h3 className="font-semibold">{update.reference}</h3>
+            <UpdateBadge status={update.status as string} />
+          </div>
+          <p className="text-sm text-muted-foreground">{update.created_at}</p>
+        </div>
+        <div className="flex space-x-2">
+          <Button variant="ghost" size="icon" aria-label="Pin update">
+            <RiPushpinLine className="h-4 w-4" />
+          </Button>
+          <Button variant="ghost" size="icon" aria-label="More options">
+            <RiMoreLine className="h-4 w-4" />
+          </Button>
+        </div>
+      </div>
+      <Divider />
+    </>
+  )
+}
+
+export default SingleUpdate;
diff --git a/web/ui/src/lib/query-constants.ts b/web/ui/src/lib/query-constants.ts
index 7750d968..dafdec7f 100644
--- a/web/ui/src/lib/query-constants.ts
+++ b/web/ui/src/lib/query-constants.ts
@@ -1,1 +1,3 @@
 export const CREATE_CONTACT_MUTATION = "CREATE_CONTACT_MUTATION" as const
+export const CREATE_UPDATE = "CREATE_UPDATE" as const
+export const LIST_UPDATES = 'LIST_UPDATES' as const
diff --git a/web/ui/tailwind.config.ts b/web/ui/tailwind.config.ts
index da185450..8d2742e4 100644
--- a/web/ui/tailwind.config.ts
+++ b/web/ui/tailwind.config.ts
@@ -60,7 +60,11 @@ const config: Config = {
       },
     },
   },
-  plugins: [require('@tailwindcss/forms')],
+  plugins: [
+    require('@tailwindcss/forms'),
+    require("@tailwindcss/typography"),
+    require("tailwindcss-animate"),
+  ],
 }
 
 export default config


Test patch:
diff --git a/internal/datastore/postgres/testdata/fixtures/updates.yml b/internal/datastore/postgres/testdata/fixtures/updates.yml
new file mode 100644
index 00000000..ccf94137
--- /dev/null
+++ b/internal/datastore/postgres/testdata/fixtures/updates.yml
@@ -0,0 +1,36 @@
+- id: 0011a73a-64c4-41b2-9c08-dd2416ed99b2
+  workspace_id: c12da796-9362-4c70-b2cb-fc8a1eba2526
+  status: draft
+  reference: update_cRiQsRRHR
+  created_by: 1aa6b38e-33d3-499f-bc9d-3090738f29e6
+  sent_by: null
+  content: ""
+  metadata: "{}"
+  sent_at: null
+  created_at: "2024-09-16 17:54:05.451647+00"
+  updated_at: "2024-09-16 17:54:05.451647+00"
+  deleted_at: null
+- id: 07b0c648-12fd-44fc-a280-946de2700e65
+  workspace_id: c12da796-9362-4c70-b2cb-fc8a1eba2526
+  status: draft
+  reference: update_O-54dq6IR
+  created_by: 1aa6b38e-33d3-499f-bc9d-3090738f29e6
+  sent_by: null
+  content: ""
+  metadata: "{}"
+  sent_at: null
+  created_at: "2024-09-15 19:18:13.35185+00"
+  updated_at: "2024-09-15 19:18:13.35185+00"
+  deleted_at: null
+- id: 0902ef67-903e-47b8-8f9d-111a9e0ca0c7
+  workspace_id: c12da796-9362-4c70-b2cb-fc8a1eba2526
+  status: draft
+  reference: update_NCox_gRNg
+  created_by: 1aa6b38e-33d3-499f-bc9d-3090738f29e6
+  sent_by: null
+  content: ""
+  metadata: "{}"
+  sent_at: null
+  created_at: "2024-09-16 18:42:56.040247+00"
+  updated_at: "2024-09-16 18:42:56.040247+00"
+  deleted_at: null
diff --git a/internal/datastore/postgres/update_test.go b/internal/datastore/postgres/update_test.go
new file mode 100644
index 00000000..8f7465d1
--- /dev/null
+++ b/internal/datastore/postgres/update_test.go
@@ -0,0 +1,89 @@
+package postgres
+
+import (
+	"context"
+	"testing"
+
+	"github.com/ayinke-llc/malak"
+	"github.com/google/uuid"
+	"github.com/stretchr/testify/require"
+)
+
+func TestUpdates_Create(t *testing.T) {
+
+	client, teardownFunc := setupDatabase(t)
+	defer teardownFunc()
+
+	updatesRepo := NewUpdatesRepository(client)
+	userRepo := NewUserRepository(client)
+	workspaceRepo := NewWorkspaceRepository(client)
+
+	// user from the fixtures
+	user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{
+		Email: "lanre@test.com",
+	})
+	require.NoError(t, err)
+
+	// from workspaces.yml migration
+	workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{
+		ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"),
+	})
+	require.NoError(t, err)
+
+	err = updatesRepo.Create(context.Background(), &malak.Update{
+		WorkspaceID: workspace.ID,
+		Status:      malak.UpdateStatusDraft,
+		CreatedBy:   user.ID,
+		Content:     "",
+		Reference:   "update_ifjfkjfo",
+	})
+	require.NoError(t, err)
+}
+
+func TestUpdates_List(t *testing.T) {
+
+	client, teardownFunc := setupDatabase(t)
+	defer teardownFunc()
+
+	updatesRepo := NewUpdatesRepository(client)
+	userRepo := NewUserRepository(client)
+	workspaceRepo := NewWorkspaceRepository(client)
+
+	// user from the fixtures
+	user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{
+		Email: "lanre@test.com",
+	})
+	require.NoError(t, err)
+	require.NotNil(t, user)
+
+	// from workspaces.yml migration
+	workspace, err := workspaceRepo.Get(context.Background(), &malak.FindWorkspaceOptions{
+		ID: uuid.MustParse("a4ae79a2-9b76-40d7-b5a1-661e60a02cb0"),
+	})
+	require.NoError(t, err)
+
+	updates, err := updatesRepo.List(context.Background(), malak.ListUpdateOptions{
+		WorkspaceID: workspace.ID,
+		Status:      malak.ListUpdateFilterStatusAll,
+	})
+	require.NoError(t, err)
+
+	require.Len(t, updates, 0)
+
+	err = updatesRepo.Create(context.Background(), &malak.Update{
+		WorkspaceID: workspace.ID,
+		Status:      malak.UpdateStatusDraft,
+		CreatedBy:   user.ID,
+		Content:     "",
+		Reference:   "update_ifjfkjfo",
+	})
+	require.NoError(t, err)
+
+	updates, err = updatesRepo.List(context.Background(), malak.ListUpdateOptions{
+		WorkspaceID: workspace.ID,
+		Status:      malak.ListUpdateFilterStatusAll,
+	})
+	require.NoError(t, err)
+
+	require.Len(t, updates, 1)
+}
diff --git a/server/testdata/TestUpdatesHandler_Create/created_update.golden b/server/testdata/TestUpdatesHandler_Create/created_update.golden
new file mode 100644
index 00000000..370d7e86
--- /dev/null
+++ b/server/testdata/TestUpdatesHandler_Create/created_update.golden
@@ -0,0 +1,1 @@
+{"update":{"id":"00000000-0000-0000-0000-000000000000","workspace_id":"00000000-0000-0000-0000-000000000000","status":"draft","reference":"update_oopsoops","created_by":"00000000-0000-0000-0000-000000000000","sent_by":"00000000-0000-0000-0000-000000000000","title":"","metadata":{},"created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"},"message":"update successfully created"}
diff --git a/server/testdata/TestUpdatesHandler_Create/culd_not_create_update.golden b/server/testdata/TestUpdatesHandler_Create/culd_not_create_update.golden
new file mode 100644
index 00000000..54d2085e
--- /dev/null
+++ b/server/testdata/TestUpdatesHandler_Create/culd_not_create_update.golden
@@ -0,0 +1,1 @@
+{"message":"could not create a new update"}
diff --git a/server/testdata/TestUpdatesHandler_List/culd_not_list_update.golden b/server/testdata/TestUpdatesHandler_List/culd_not_list_update.golden
new file mode 100644
index 00000000..67f83da2
--- /dev/null
+++ b/server/testdata/TestUpdatesHandler_List/culd_not_list_update.golden
@@ -0,0 +1,1 @@
+{"message":"could not list updates"}
diff --git a/server/testdata/TestUpdatesHandler_List/listed_updates.golden b/server/testdata/TestUpdatesHandler_List/listed_updates.golden
new file mode 100644
index 00000000..546e987e
--- /dev/null
+++ b/server/testdata/TestUpdatesHandler_List/listed_updates.golden
@@ -0,0 +1,1 @@
+{"updates":[{"id":"00000000-0000-0000-0000-000000000000","workspace_id":"00000000-0000-0000-0000-000000000000","reference":"update_12345","created_by":"00000000-0000-0000-0000-000000000000","sent_by":"00000000-0000-0000-0000-000000000000","metadata":{},"created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z","title":""}],"meta":{"paging":{"per_page":8,"page":1}},"message":"updates fetched"}
diff --git a/server/update_test.go b/server/update_test.go
new file mode 100644
index 00000000..2329de92
--- /dev/null
+++ b/server/update_test.go
@@ -0,0 +1,164 @@
+package server
+
+import (
+	"bytes"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/ayinke-llc/malak"
+	malak_mocks "github.com/ayinke-llc/malak/mocks"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/mock/gomock"
+)
+
+func TestUpdatesHandler_Create(t *testing.T) {
+	for _, v := range generateUpdateCreateTestTable() {
+
+		t.Run(v.name, func(t *testing.T) {
+
+			controller := gomock.NewController(t)
+			defer controller.Finish()
+
+			updateRepo := malak_mocks.NewMockUpdateRepository(controller)
+
+			v.mockFn(updateRepo)
+
+			u := &updatesHandler{
+				referenceGenerator: &mockReferenceGenerator{},
+				updateRepo:         updateRepo,
+			}
+
+			var b = bytes.NewBuffer(nil)
+
+			rr := httptest.NewRecorder()
+
+			req := httptest.NewRequest(http.MethodPost, "/", b)
+			req.Header.Add("Content-Type", "application/json")
+
+			req = req.WithContext(writeUserToCtx(req.Context(), &malak.User{}))
+			req = req.WithContext(writeWorkspaceToCtx(req.Context(), &malak.Workspace{}))
+
+			WrapMalakHTTPHandler(getLogger(t), u.create, getConfig(), "updates.new").
+				ServeHTTP(rr, req)
+
+			require.Equal(t, v.expectedStatusCode, rr.Code)
+			verifyMatch(t, rr)
+		})
+	}
+}
+
+func generateUpdateCreateTestTable() []struct {
+	name               string
+	mockFn             func(update *malak_mocks.MockUpdateRepository)
+	expectedStatusCode int
+} {
+
+	return []struct {
+		name               string
+		mockFn             func(update *malak_mocks.MockUpdateRepository)
+		expectedStatusCode int
+	}{
+		{
+			name: "culd not create update",
+			mockFn: func(update *malak_mocks.MockUpdateRepository) {
+				update.
+					EXPECT().
+					Create(gomock.Any(), gomock.Any()).
+					Times(1).
+					Return(errors.New("could not create update"))
+
+			},
+			expectedStatusCode: http.StatusInternalServerError,
+		},
+		{
+			name: "created update",
+			mockFn: func(update *malak_mocks.MockUpdateRepository) {
+				update.
+					EXPECT().
+					Create(gomock.Any(), gomock.Any()).
+					Times(1).
+					Return(nil)
+			},
+			expectedStatusCode: http.StatusCreated,
+		},
+	}
+}
+
+func generateUpdateListTestTable() []struct {
+	name               string
+	mockFn             func(update *malak_mocks.MockUpdateRepository)
+	expectedStatusCode int
+} {
+
+	return []struct {
+		name               string
+		mockFn             func(update *malak_mocks.MockUpdateRepository)
+		expectedStatusCode int
+	}{
+		{
+			name: "culd not list update",
+			mockFn: func(update *malak_mocks.MockUpdateRepository) {
+				update.
+					EXPECT().
+					List(gomock.Any(), gomock.Any()).
+					Times(1).
+					Return(nil, errors.New("could not list update"))
+
+			},
+			expectedStatusCode: http.StatusInternalServerError,
+		},
+		{
+			name: "listed updates",
+			mockFn: func(update *malak_mocks.MockUpdateRepository) {
+				update.
+					EXPECT().
+					List(gomock.Any(), gomock.Any()).
+					Times(1).
+					Return([]malak.Update{
+						{
+							Reference: malak.Reference("update_12345"),
+						},
+					}, nil)
+			},
+			expectedStatusCode: http.StatusCreated,
+		},
+	}
+}
+
+func TestUpdatesHandler_List(t *testing.T) {
+	for _, v := range generateUpdateListTestTable() {
+
+		t.Run(v.name, func(t *testing.T) {
+
+			controller := gomock.NewController(t)
+			defer controller.Finish()
+
+			updateRepo := malak_mocks.NewMockUpdateRepository(controller)
+
+			v.mockFn(updateRepo)
+
+			u := &updatesHandler{
+				referenceGenerator: &mockReferenceGenerator{},
+				updateRepo:         updateRepo,
+			}
+
+			var b = bytes.NewBuffer(nil)
+
+			rr := httptest.NewRecorder()
+
+			req := httptest.NewRequest(http.MethodPost, "/", b)
+			req.Header.Add("Content-Type", "application/json")
+
+			req = req.WithContext(writeUserToCtx(req.Context(), &malak.User{}))
+			req = req.WithContext(writeWorkspaceToCtx(req.Context(), &malak.Workspace{}))
+
+			WrapMalakHTTPHandler(getLogger(t), u.list, getConfig(), "updates.list").
+				ServeHTTP(rr, req)
+
+			require.Equal(t, v.expectedStatusCode, rr.Code)
+			verifyMatch(t, rr)
+		})
+	}
+}
diff --git a/update_title_test.go b/update_title_test.go
new file mode 100644
index 00000000..2156c185
--- /dev/null
+++ b/update_title_test.go
@@ -0,0 +1,72 @@
+package malak
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func BenchmarkGetFirstHeader(b *testing.B) {
+	markdown := `# First Title
+	
+Some content here
+	
+## Second Title
+More content`
+
+	_, _ = getFirstHeader(UpdateContent(markdown))
+}
+
+func TestGetFirstHeader(t *testing.T) {
+
+	tt := []struct {
+		name     string
+		content  string
+		expected string
+	}{
+		{
+			name: "properly formatted markdown",
+			content: `
+# First Title
+	
+Some content here
+	
+## Second Title
+More content
+			`,
+			expected: "Second Title",
+		},
+		{
+			name: "poorly formatted markdown",
+			content: `
+# First Title
+	
+Some content here
+	
+#### weird Title
+More content
+
+## real header
+			`,
+			expected: "real header",
+		},
+		{
+			name: "no 2nd header",
+			content: `
+# First Title
+	
+Some content here
+			`,
+			expected: "",
+		},
+	}
+
+	for _, v := range tt {
+		t.Run(v.name, func(t *testing.T) {
+			title, err := getFirstHeader(UpdateContent(v.content))
+			require.NoError(t, err)
+
+			require.Equal(t, v.expected, title)
+		})
+	}
+}
diff --git a/web/ui/src/components/ui/updates/button/send-test.tsx b/web/ui/src/components/ui/updates/button/send-test.tsx
new file mode 100644
index 00000000..e27036ff
--- /dev/null
+++ b/web/ui/src/components/ui/updates/button/send-test.tsx
@@ -0,0 +1,122 @@
+import { Button } from "@/components/Button";
+import {
+  Dialog,
+  DialogClose,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/Dialog";
+import { Input } from "@/components/Input";
+import { Label } from "@/components/Label";
+import { Switch } from "@/components/Switch";
+import { yupResolver } from "@hookform/resolvers/yup";
+import { RiEyeLine } from "@remixicon/react";
+import { useState } from "react";
+import { SubmitHandler, useForm } from "react-hook-form";
+import * as yup from "yup";
+import { ButtonProps } from "./props";
+
+type PreviewUpdateInput = {
+  email: string
+  link?: boolean
+}
+
+const schema = yup
+  .object({
+    email: yup.string().min(5).max(50).required(),
+    link: yup.boolean().optional(),
+  })
+  .required()
+
+const SendTestButton = ({ }: ButtonProps) => {
+
+  const [loading, setLoading] = useState<boolean>(false)
+
+  const {
+    register,
+    formState: { errors },
+    handleSubmit,
+  } = useForm({
+    resolver: yupResolver(schema),
+  })
+
+  const onSubmit: SubmitHandler<PreviewUpdateInput> = (data) => {
+    setLoading(true)
+  }
+
+  return (
+    <>
+      <div className="flex justify-center">
+        <Dialog>
+          <DialogTrigger asChild>
+            <Button type="button"
+              variant="secondary" size="lg" className="gap-1">
+              <RiEyeLine size={18} />
+              Preview
+            </Button>
+          </DialogTrigger>
+          <DialogContent className="sm:max-w-lg">
+            <form onSubmit={handleSubmit(onSubmit)}>
+              <DialogHeader>
+                <DialogTitle>
+                  Send a test email
+                </DialogTitle>
+                <DialogDescription className="mt-1 text-sm leading-6">
+                  Send a test email to preview your updates before
+                  sending to your investors
+                </DialogDescription>
+
+                <div className="mt-4">
+                  <Label htmlFor="workspace-name" className="font-medium">
+                    Email address
+                  </Label>
+                  <Input
+                    id="email"
+                    placeholder="yo@lanre.wtf"
+                    className="mt-2"
+                    type="email"
+                    {...register("email")}
+                  />
+                  {errors.email && (
+                    < p className="mt-4 text-xs text-red-600 dark:text-red-500">
+                      <span className="font-medium">
+                        {errors.email.message}
+                      </span>
+                    </p>
+                  )}
+                </div>
+
+                <div className="mt-4">
+                  <Switch disabled id="r3" {...register("link")} />
+                  <Label disabled htmlFor="r3">
+                    Coming soon. Generate a test preview link you can share
+                  </Label>
+                </div>
+              </DialogHeader>
+              <DialogFooter className="mt-6">
+                <DialogClose asChild>
+                  <Button
+                    type={"button"}
+                    className="mt-2 w-full sm:mt-0 sm:w-fit"
+                    variant="secondary"
+                  >
+                    Cancel
+                  </Button>
+                </DialogClose>
+                <Button type="submit"
+                  className="w-full sm:w-fit">
+                  Send
+                </Button>
+              </DialogFooter>
+            </form>
+          </DialogContent>
+        </Dialog>
+      </div>
+    </>
+  )
+}
+
+export default SendTestButton;