diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md new file mode 100644 index 0000000..33b3b9e --- /dev/null +++ b/.github/CHANGELOG.md @@ -0,0 +1,15 @@ + +# Changelog + +### Analytics V3 +1. Service now written in Go for resource efficiency and speed. +2. New `GET` `/v3/campaign/:id/interaction/:iID` endpoint to get campaign data for a specific interaction +3. JSON responses are now returned in streamlined Page schema +4. Deprecated V1 API - will continue to function but will be removed in a future release (see Migration Guide) + +### Analytics V2 +1. New endpoints and database schema to handle Interactions, a granular way to track campaign performance +2. New endpoint to get campaign data without creating a visit + +### Analytics V1 +1. Initial release \ No newline at end of file diff --git a/.github/MIGRATION.md b/.github/MIGRATION.md index a3fe68a..0d5f72b 100644 --- a/.github/MIGRATION.md +++ b/.github/MIGRATION.md @@ -1,12 +1,58 @@ -# Analytics V1 to V2 Migration +# Migration Guide +This migration guide is a comprehensive guide to the changes you need to make in Notion and in your code to upgrade your analytics version. + +## V2 -> V3 Migration + +### In Notion +V3 uses the exact same database format as V2, so you can keep your existing Notion database and integration. + +### In Code +You need to update any services calling the analytics API. +1. All V2 endpoints have a V3 endpoint that is functionally equivalent, so you can just update the endpoint from /v2/ to /v3/ as shown below. + +| method | old endpoint | new endpoint | +|---|---|---| +| GET | `/v2/campaign/:id` | `/v3/campaign/:id` | +| POST | `/v2/campaign/:id` | `/v3/campaign/:id` | +| POST | `/v2/campaign/:id/interaction` | `/v3/campaign/:id/interaction` | +| POST | `/v2/campaign/:id/interaction/:interactionId` | `/v3/campaign/:id/interaction/:interactionId` | +| POST | `/v2/campaign/:id/visit/:interactionId` | `/v3/campaign/:id/visit/:interactionId` | + +2. If you use JSON responses from the API (If `Public`=`True` for any of your campaigns), then you need to alter your code to use the new response schema as shown below. If you aren't using Public campaigns, then successes will continue returning 204 status codes and you can ignore this step. + +| property name | type | +|---|---| +| `id` | `string` | +| `campaign_id` | `string` | +| `parent_campaigns` | `string[]` | +| `interact` | `string` | +| `public` | `string` | +| `ref_visits` | `number` | +| `visits` | `number` | +| `interactions` | `number` | + +You can find the documentation on the rest of the new endpoints in the README. + +--- + +## V1 -> V2 Migration + +### In Notion Using your existing Notion database: 1. Create an property of type `Number` named `Interactions`. 2. Create a property of type `Select` named `Interact` with the options `Enabled`, `Disabled`, and `Dynamic`. 3. Ensure you have a property called `CreatedBy` of type `Created by`. 4. Enable your database's `Subtasks` feature (using the `ParentCampaign` and `SubCampaigns` property names respectively) for better visual organization. Alternatively, just ensure the `ParentCampaign` of type `Relation` property exists. -Then, you can deploy your new Analytics instance (example instructions in README) and start using the new APIs, documented there as well. +### In Code +The only API endpoint in V1 is forward-compatible with V2, so you can just update the endpoint from /v1/ to /v2/ as shown below. + +| method | old endpoint | new endpoint | +|---|---|---| +| POST | `/v1/campaign/:id` | `/v2/campaign/:id` | + +You can find the documentation on the rest of the new endpoints in the README. Your new Analytics instance and database format is still compatible with your `redirect` instance, if you are running the two alongside each other. \ No newline at end of file diff --git a/.github/README.md b/.github/README.md index 2ee6097..8c1f6d4 100644 --- a/.github/README.md +++ b/.github/README.md @@ -1,19 +1,20 @@ # analytics -Notion-integrated analytics API with interaction tracking. Features user-definable and app-controlled management of KPIs. -> ⚠️ Are you an Analytics V1 user? See the [V2 migration guide](./MIGRATION.md) to see how you can preserve your existing data and make use of the new Interactions API and App-Controlled Campaigns. The V1 api will continue to function as-is. +![https://img.shields.io/github/v/release/ivynya/analytics?label=version](https://img.shields.io/github/v/release/ivynya/analytics?label=version) -## Using the Notion Template +Notion-integrated analytics solution with interaction tracking. Features user-definable and app-controlled management of different campaigns and KPIs by API and visually through a Notion database. + +> ⚠️ Are you an Analytics V1 or V2 user? See the [migration guide](./MIGRATION.md) to see how you can preserve your existing data and make use of new features and performance improvements in V3. No rush, though! V1 and V2 API endpoints are still supported and functional for now. -To get started, duplicate this Notion page template: [ivy.direct/template/analytics/v2](https://ivy.direct/template/analytics/v2) +## Using the Notion Template -> ⚠️ The Analytics V2 template is compatible with Analytics V1 and is the recommended default. However, if you only want V1 features, you can use the old template: [ivy.direct/template/analytics/v1](https://ivy.direct/template/analytics/v1) +To get started, duplicate this Notion page template: [ivy.direct/template/analytics/v3](https://ivy.direct/template/analytics/v3) -[![Notion Template](https://github.com/ivynya/analytics/raw/main/.github/v2_template.jpg)](https://ivy.direct/template/analytics/v2) +[![Notion Template](./v3_template.jpg)](https://ivy.direct/template/analytics/v3) Do not edit or delete any of the property names as the API requires these to function. You can add additional properties for organizational purposes, or create views that hide the properties you don't need instead. -### API V2 Property Defintions +### V3 Notion Property Defintions | Property | Description | | --- | --- | | `Campaign` | A friendly name for the campaign. Can be anything. | @@ -28,31 +29,60 @@ Do not edit or delete any of the property names as the API requires these to fun | `SubCampaigns` (hidden by default) | Any associated sub-campaigns. Not used by the API directly. | | `CreatedBy` (hidden by default) | Shows who created this campaign (you, another user, or the Analytics API) | -## Hosting the API - -### With Deno Deploy -1. [Create a new Notion integration](https://www.notion.so/my-integrations) with all permissions, copy the API token, and invite it to your duplicated Notion page. If you don't want to use the App-Controlled Campaigns feature, you can safely leave out the Insert Content permission. -2. Fork this repository's `/deployable` branch and create a new automatic [Deno Deploy](https://deno.com/deploy) instance from it, adding the `NOTION_TOKEN` (from your integration) and `NOTION_DB_ID` (from your duplicated Notion page's ID: see [Notion's guide on how to find this](https://developers.notion.com/docs/create-a-notion-integration#step-3-save-the-database-id)) as environment variables -3. Make a `POST` request to `https://YOUR-SUBDOMAIN-HERE.deno.dev/v2/campaign/portfolio-github` to see it work. +## Hosting the Analytics API +1. [Create a new Notion integration](https://www.notion.so/my-integrations) with all permissions, copy the API token, and invite it to your duplicated Notion page. +2. Pull the Docker image artifact from GitHub Container Registry +3. Clone this repo and create a `.env` file with the ID and token, according to `.env.example` +4. Run the Docker container and pass in the environment variables: `docker run -p 3000:3000 --env-file .env ivynya/analytics` +5. Make a `POST` request to `http://localhost:3000/v3/campaign/portfolio-github` to see it work. -### As Docker Container -1. [Create a new Notion integration](https://www.notion.so/my-integrations) with all permissions, copy the API token, and invite it to your duplicated Notion page. If you don't want to use the App-Controlled Campaigns feature, you can safely leave out the Insert Content permission. -2. Clone this repo and create a `.env` file with the ID and token, according to `.env.example` -3. Run `docker build -t analytics .` and `docker run -p 8000:8000 -d analytics` -4. Make a `POST` request to `http://localhost:8000/v2/campaign/portfolio-github` to see it work. -5. If you don't want to build the container with your secrets, you can also use Docker environment flags to pass these values in at runtime. +## V3 API - Endpoints +The following API endpoints are described in the table below. Each endpoint may return one of the listed HTTP status codes in the `Returns` column, as well as the additional possibility of a `200 OK` (described by the Standard Response Protocol further below) or a `500` error if things go catastrophically wrong. -## API Usage | Endpoint | Description | Returns | | --- | --- | --- | -| `GET /v2/campaign/:CampaignID` | Gets campaign info as JSON response. | `200` or `400` if campaign not found or Public = false | -| `POST /v2/campaign/:CampaignID` | Registers +1 Visit. If the campaign is a sub-campaign, the parent will also be updated with +1 RefVisit and +1 Visit. | `204` or `400` if campaign not found | -| `POST /v2/campaign/:CampaignID /interact` | Registers +1 Interaction. If the campaign is a sub-campaign, the parent will also be updated with +1 Interaction. | `204` or `400` if campaign not found or Interact = Disabled | -| `POST /v2/campaign/:CampaignID /interact/:InteractionID` | Creates a sub-campaign for :CampaignID with default values. If exists already, registers +1 interaction. | `204` or `400` if campaign not found | -| `POST /v2/campaign/:CampaignID /visit/:InteractionID` | Creates a sub-campaign for :CampaignID with default values. If exists already, registers +1 visit. | `204` or `400` if campaign not found | +| `GET /v3/campaign/:CampaignID` | Gets campaign info as JSON response. | `200`, `204`, `400` SRP(Campaign) | +| `POST /v3/campaign/:CampaignID` | Registers +1 Visit. If the campaign is a sub-campaign, the parent will also be updated with +1 RefVisit and +1 Visit. | `200`, `204`, `400` SRP(Campaign) | +| `POST /v3/campaign/:CampaignID /interaction` | Registers +1 Interaction. If the campaign is a sub-campaign, the parent will also be updated with +1 Interaction. | `200`, `204`, `400` SRP(Campaign) | +| `GET /v3/campaign/:CampaignID /interaction/:InteractionID` | Gets interaction info as JSON response. | `200`, `204`, `400` SRP(Subcampaign) | +| `POST /v3/campaign/:CampaignID /interact/:InteractionID` | Creates a sub-campaign for :CampaignID with default values. If exists already, registers +1 interaction. | `200`, `204`, `400` SRP(Subcampaign) | +| `POST /v3/campaign/:CampaignID /visit/:InteractionID` | Creates a sub-campaign for :CampaignID with default values. If exists already, registers +1 visit. | `200`, `204`, `400` SRP(Subcampaign) | + +After Analytics makes an edit, you can see a summary of all unread changes in the Notion history for the page. + +## V3 API - Standard Response Protocol (SRP) + +The standard response protocol is a flow that describes potential responses from the API if the given Campaign or Interaction is set to `Public` = `True`or not in the Notion database. + +For **SRP(Campaign)** endpoints described above, if `Public` is `True` and the API request is successful, the endpoint will return a `200 OK` response with the following JSON schema describing the `Campaign` that corresponds to the `:CampaignID` called: -After Analytics makes an edit, you can see a summary of all unread changes as a Notion update (if you follow the page, which is true by default): -[![Notion Update](./v2_example.jpg)](https://ivy.direct/template-analytics) +```ts +{ + "ID": string // Notion ID of the campaign + "CampaignID": string, // User-defined ID of the campaign + "Visits": number, // Total visits to the campaign + "RefVisits": number, // Total visits to the campaign from sub-campaigns + "Interactions": number, // Total interactions with the campaign + "Public": string, // "True" or "False" + "Interact": "Dynamic", // "Dynamic" "Enabled" or "Disabled" - Dynamic allows API requests to create sub-campaigns to track interactions, Enabled allows API requests to track interactions, Disabled does not allow API requests to track interactions at all +} +``` + +If the campaign is not public (`Public` = `False`), and the API request was otherwise successful, the API will return a `204 No Content` response. + +For **SRP(Interaction)** endpoints described in the table above, the exact same response flow and JSON schema are used - except, instead of describing `:CampaignID`, it describes the campaign object represented by `:CampaignID-:InteractionID` (`:InteractionID` subcampaign of `:CampaignID`) instead. + +## API Support Matrix + +The following table shows which Analytics release supports which API versions. Use this table to inform your decision on whether or not you can upgrade to a newer version of Analytics while still using old API versions. + +**❌ = Not Supported | ✅ = Supported | ⚠️ = Deprecated, Functional** + +| API Version | Analytics V1 | Analytics V2 | Analytics V3 | +| --- | --- | --- | --- | +| V1 | ✅ | ✅ | ⚠️ | +| V2 | ❌ | ✅ | ⚠️ | +| V3 | ❌ | ❌ | ✅ | ## Suggested Usage I use top-level campaigns to track a project as a whole (total visits, referrals to it, and interactions on that project) in combination with my [redirect](https://github.com/ivynya/redirect) service. @@ -64,9 +94,11 @@ Finally, for items that require dynamic tracking, I use the `Interact` property These analytics hits are typically done server-side to prevent being blocked by scripts or slowing down page loads. Because no "creepy" or personally-identifiable data (that I wouldn't be able to use anyway) like location, IP, device specifications, mouse cursor movement, etc etc is collected - only page hit numbers and interactions are - this is a great way to track user behavior without being invasive. ## Compatibility with [ivynya/redirect](https://github.com/ivynya/redirect) -Analytics V2 remains compatible with `redirect` to track visits for dynamic redirects, managed from Notion. See the `redirect` GitHub page for setup and usage. +Analytics V3 remains compatible with `redirect` to track visits for dynamic redirects, managed from Notion. See the `redirect` GitHub page for setup and usage. ## Licensing & Contributing -Contributions are welcome! Please first open an issue on this repository. +Contributions are welcome! Please first open an issue on this repository before making a pull request. + +To run the project locally, you need Go installed (tested on 1.20+). Create a .env file according to the .env.example and then source it into your environment. Then, run `go run ./cmd/analytics` to start the server on localhost:3000. -This repository lives under MIT license. +This repository lives under MIT license. \ No newline at end of file diff --git a/.github/v2_example.jpg b/.github/v2_example.jpg deleted file mode 100644 index 21d3cec..0000000 Binary files a/.github/v2_example.jpg and /dev/null differ diff --git a/.github/v3_template.jpg b/.github/v3_template.jpg new file mode 100644 index 0000000..565db57 Binary files /dev/null and b/.github/v3_template.jpg differ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..2d46e2f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,26 @@ +name: Build and Push Docker Image to GHCR + +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Login to GHCR + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: ghcr.io/${{ github.repository }}/analytics:latest + diff --git a/.gitignore b/.gitignore index 34b44fa..1c6e718 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ -.env -notion/cache.json +.env \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 41cffb2..c64d63c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { - "deno.enable": true, - "deno.suggest.imports.hosts": { - "https://deno.land": true - } + "go.toolsEnvVars": { + "GO111MODULE": "on" + }, + "dotenv.enableAutocloaking": false } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f2a4918..dd1b8dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,14 @@ - -FROM denoland/deno:alpine-1.23.1 - -EXPOSE 8000 - +# Use the official Golang image as the base image +FROM golang:1.21-alpine as builder WORKDIR /app - -COPY deps.ts . COPY . . +RUN go build ./cmd/analytics -RUN deno cache deps.ts -RUN deno cache main.ts +# Use slim alpine image for production +FROM alpine:3.18 as production +WORKDIR /app +COPY --from=builder /app/analytics . +EXPOSE 3000 -CMD ["run", "-A", "main.ts"] \ No newline at end of file +# Run the Go program when the container starts +CMD ["./analytics"] diff --git a/api/v1.ts b/api/v1.ts deleted file mode 100644 index bd7a330..0000000 --- a/api/v1.ts +++ /dev/null @@ -1,35 +0,0 @@ - -import { Router } from "../deps.ts"; -import { getPage } from "../notion/getPage.ts"; -import { queryDatabase } from "../notion/queryDatabase.ts"; -import { updateVisits } from "../notion/updatePage.ts"; - -const db = await queryDatabase(); -const buffer: { [id: string]: number } = {}; -db.forEach(page => { - buffer[page.id] = 0; -}); - -setInterval(async () => { - const queue = Object.keys(buffer).filter(id => buffer[id] > 0); - const pages = await Promise.all(queue.map(id => getPage(id))); - await Promise.all(pages.map(page => updateVisits(page, buffer[page.id]))); - Object.keys(buffer).forEach(id => buffer[id] = 0); -}, 5000); - -export const api = new Router(); -api - .post("/v1/campaign/:id", async ctx => { - const campaigns = await queryDatabase(); - const campaign = campaigns.find(c => c.CampaignID === ctx.params.id); - if (campaign) { - console.log(`[v1]: ${ctx.params.id}`) - buffer[campaign.id]++; - if (campaign.Public.name == "True") - ctx.response.body = campaign; - else ctx.response.status = 204; - } else { - ctx.response.status = 400; - ctx.response.body = "Campaign not found"; - } - }); \ No newline at end of file diff --git a/api/v2.ts b/api/v2.ts deleted file mode 100644 index e2cccdf..0000000 --- a/api/v2.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Router } from "../deps.ts"; -import { createPage } from "../notion/createPage.ts"; -import { getPage } from "../notion/getPage.ts"; -import { queryDatabase } from "../notion/queryDatabase.ts"; -import { updateInteractions, updateVisits } from "../notion/updatePage.ts"; -import type { Buffer } from "../schema/buffer.ts"; - -const db = await queryDatabase(); -const buffer: Buffer = {}; -db.forEach((page) => { - buffer[page.id] = { - visits: 0, - interactions: 0, - }; -}); - -setInterval(() => { - Object.keys(buffer) - .filter((id) => buffer[id].interactions > 0 || buffer[id].visits > 0) - .map(async (id) => { - const page = await getPage(id); - await updateInteractions(page, buffer[id].interactions) - await updateVisits(page, buffer[id].visits); - buffer[id].interactions = 0; - buffer[id].visits = 0; - }); -}, 5000); - -export const api = new Router(); -api - .get("/v2/campaign/:id", async (ctx) => { - const campaign = (await queryDatabase()).find((c) => c.CampaignID === ctx.params.id); - if (!campaign || campaign.Public.name != "True") { - ctx.response.status = 400; - ctx.response.body = "Campaign not found"; - } - ctx.response.body = JSON.stringify(campaign); - console.log(`[v2]: GET ${ctx.params.id}`); - }) - .post("/v2/campaign/:id", async (ctx) => { - const campaign = (await queryDatabase()).find((c) => c.CampaignID === ctx.params.id); - if (!campaign) { - ctx.response.status = 400; - ctx.response.body = "Campaign not found"; - } - console.log(`[v2]: ${ctx.params.id}`); - buffer[campaign.id].visits++; - if (campaign.Public.name == "True") - ctx.response.body = campaign; - else ctx.response.status = 204; - }) - .post("/v2/campaign/:id/interaction", async (ctx) => { - const campaign = (await queryDatabase()).find((c) => c.CampaignID === ctx.params.id); - if (!campaign) { - ctx.response.status = 400; - ctx.response.body = "Campaign not found"; - } - console.log(`[v2]: ${ctx.params.id}::${ctx.params.iid}`); - buffer[campaign.id].interactions++; - if (campaign.Public.name == "True") - ctx.response.body = campaign; - else ctx.response.status = 204; - }) - .post("/v2/campaign/:id/interaction/:iid", async (ctx) => { - const campaign = (await queryDatabase()).find((c) => c.CampaignID === ctx.params.id); - if (!campaign || campaign.Interact.name != "Dynamic") { - ctx.response.status = 400; - ctx.response.body = "Cannot create interaction for this campaign" - return; - } - const interactionID = `${ctx.params.id}-${ctx.params.iid}`; - const interaction = (await queryDatabase()).find((c) => c.CampaignID === interactionID); - if (interaction) { - console.log(`[v2]: ${ctx.params.id}::${ctx.params.iid}`); - if (!buffer[interaction.id]) buffer[interaction.id] = { visits: 0, interactions: 0, }; - buffer[interaction.id].interactions++; - ctx.response.status = 204; - return; - } - console.log(`[v2]: ${ctx.params.id}++${ctx.params.iid}`); - const res = await createPage(ctx.params.iid, campaign.CampaignID, campaign.id); - if (res.ok) ctx.response.status = 204; - else ctx.response.status = 400; - }) - .post("/v2/campaign/:id/visit/:iid", async (ctx) => { - const campaign = (await queryDatabase()).find((c) => c.CampaignID === ctx.params.id); - if (!campaign || campaign.Interact.name != "Dynamic") { - ctx.response.status = 400; - ctx.response.body = "Cannot create visit for this campaign" - return; - } - const interactionID = `${ctx.params.id}-${ctx.params.iid}`; - const interaction = (await queryDatabase()).find((c) => c.CampaignID === interactionID); - if (interaction) { - console.log(`[v2]: ${ctx.params.id}~~${ctx.params.iid}`); - if (!buffer[interaction.id]) buffer[interaction.id] = { visits: 0, interactions: 0, }; - buffer[interaction.id].visits++; - ctx.response.status = 204; - return; - } - console.log(`[v2]: ${ctx.params.id}++${ctx.params.iid}`); - const res = await createPage(ctx.params.iid, campaign.CampaignID, campaign.id); - if (res.ok) ctx.response.status = 204; - else ctx.response.status = 400; - }); \ No newline at end of file diff --git a/cmd/analytics/buffer.go b/cmd/analytics/buffer.go new file mode 100644 index 0000000..90c550f --- /dev/null +++ b/cmd/analytics/buffer.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "log" + "time" + + analytics "github.com/ivynya/analytics/pkg" +) + +type Buffer map[string]BufferData +type BufferData struct { + Visits int + Interactions int +} + +var buffer = make(Buffer) + +// Queues update to campaign with nID and visits/interactions +func bufferData(nID string, visits int, interactions int) { + if visits <= 0 && interactions <= 0 { + return + } + + if _, ok := buffer[nID]; ok { + buffer[nID] = BufferData{ + Visits: buffer[nID].Visits + visits, + Interactions: buffer[nID].Interactions + interactions, + } + } else { + buffer[nID] = BufferData{ + Visits: visits, + Interactions: interactions, + } + } +} + +// Goroutine that pushes updates to Notion every 10 seconds +func bufferFlushLoop() { + for { + time.Sleep(10 * time.Second) + if len(buffer) > 0 { + log.Println("[UPD] " + fmt.Sprint(len(buffer)) + " campaigns") + } + + for nID, data := range buffer { + err := analytics.UpdateVisits(nID, data.Visits, false) + if err != nil { + log.Println("[ERR] " + err.Error()) + } + err = analytics.UpdateInteractions(nID, data.Interactions) + if err != nil { + log.Println("[ERR] " + err.Error()) + } + delete(buffer, nID) + } + } +} diff --git a/cmd/analytics/main.go b/cmd/analytics/main.go new file mode 100644 index 0000000..5e526ff --- /dev/null +++ b/cmd/analytics/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "log" + "os" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + _ "github.com/joho/godotenv/autoload" +) + +func main() { + go bufferFlushLoop() + + app := fiber.New() + app.Use(cors.New()) + + log.Println("[analytics] creating routers...") + + createRouterV1(app) + createRouterV2(app) + createRouterV3(app) + + tokenPresent := os.Getenv("NOTION_TOKEN") != "" + log.Println("[analytics] $NOTION_DB_ID:", os.Getenv("NOTION_DB_ID")) + log.Println("[analytics] $NOTION_TOKEN:", tokenPresent) + log.Println("[analytics] starting fiber on port 3000") + + log.Fatal(app.Listen(":3000")) +} diff --git a/cmd/analytics/v1.go b/cmd/analytics/v1.go new file mode 100644 index 0000000..f66373f --- /dev/null +++ b/cmd/analytics/v1.go @@ -0,0 +1,41 @@ +package main + +import ( + "encoding/json" + "log" + + "github.com/gofiber/fiber/v2" + analytics "github.com/ivynya/analytics/pkg" +) + +func createRouterV1(a *fiber.App) fiber.Router { + v1 := a.Group("/v1") + + // Deprecated - will be removed in analytics v4 + // Functionally identical to /v2/campaign/:cID (use instead) + v1.Post("/campaign/:cID", func(c *fiber.Ctx) error { + log.Println("[ERR] Deprecated call: /v1/campaign/" + c.Params("cID")) + cID := c.Params("cID") + campaign, err := analytics.FindCampaignByCID(cID) + if err != nil { + return c.Status(400).SendString("Campaign not found") + } + bufferData(campaign.NotionID, 1, 0) + + if campaign.Public == "True" { + unformattedCampaign, err := v2FindCampaignByCIDWithoutConvert(cID) + if err != nil { + return c.Status(400).SendString("Campaign not found") + } + jso, err := json.Marshal(unformattedCampaign) + if err != nil { + return c.Status(500).SendString("Internal server error") + } + return c.SendString(string(jso)) + } + + return c.SendStatus(204) + }) + + return v1 +} diff --git a/cmd/analytics/v2.go b/cmd/analytics/v2.go new file mode 100644 index 0000000..64f3b46 --- /dev/null +++ b/cmd/analytics/v2.go @@ -0,0 +1,149 @@ +package main + +import ( + "encoding/json" + "errors" + + "github.com/gofiber/fiber/v2" + "github.com/ivynya/analytics/internal/notion" + analytics "github.com/ivynya/analytics/pkg" +) + +func createRouterV2(a *fiber.App) fiber.Router { + v2 := a.Group("/v2") + + // Returns a campaign if it is public as PageResult form + v2.Get("/campaign/:cID", func(c *fiber.Ctx) error { + cID := c.Params("cID") + campaign, err := analytics.FindCampaignByCID(cID) + if err != nil || campaign.Public != "True" { + return c.Status(400).SendString("Campaign not found") + } + unformattedCampaign, err := v2FindCampaignByCIDWithoutConvert(cID) + if err != nil { + return c.Status(400).SendString("Campaign not found") + } + jso, err := json.Marshal(unformattedCampaign) + if err != nil { + return c.Status(500).SendString("Internal server error") + } + return c.SendString(string(jso)) + }) + + // Increments visits and returns if public as PageResult / 204 + v2.Post("/campaign/:cID", func(c *fiber.Ctx) error { + cID := c.Params("cID") + campaign, err := analytics.FindCampaignByCID(cID) + if err != nil { + return c.Status(400).SendString("Campaign not found") + } + bufferData(campaign.NotionID, 1, 0) + + if campaign.Public == "True" { + unformattedCampaign, err := v2FindCampaignByCIDWithoutConvert(cID) + if err != nil { + return c.Status(400).SendString("Campaign not found") + } + jso, err := json.Marshal(unformattedCampaign) + if err != nil { + return c.Status(500).SendString("Internal server error") + } + return c.SendString(string(jso)) + } + + return c.SendStatus(204) + }) + + // Increments interactions and returns if public as PageResult / 204 + v2.Post("/campaign/:cID/interaction", func(c *fiber.Ctx) error { + cID := c.Params("cID") + campaign, err := analytics.FindCampaignByCID(cID) + if err != nil { + return c.Status(400).SendString("Campaign not found") + } + bufferData(campaign.NotionID, 0, 1) + + if campaign.Public == "True" { + unformattedCampaign, err := v2FindCampaignByCIDWithoutConvert(cID) + if err != nil { + return c.Status(400).SendString("Campaign not found") + } + jso, err := json.Marshal(unformattedCampaign) + if err != nil { + return c.Status(500).SendString("Internal server error") + } + return c.SendString(string(jso)) + } + + return c.SendStatus(204) + }) + + // If parent dynamic, create/increment KPI interaction and returns 204 + v2.Post("/campaign/:cID/interaction/:iID", func(c *fiber.Ctx) error { + cID := c.Params("cID") + campaign, err := analytics.FindCampaignByCID(cID) + if err != nil { + return c.Status(400).SendString("Campaign not found") + } + if campaign.Interact != "Dynamic" { + return c.Status(400).SendString("Cannot create interaction for campaign") + } + iID := c.Params("cID") + "-" + c.Params("iID") + interaction, err := analytics.FindCampaignByCID(iID) + if err == nil { + bufferData(interaction.NotionID, 0, 1) + return c.SendStatus(204) + } else { + err := analytics.CreatePage(iID, cID, campaign.NotionID) + if err != nil { + return c.Status(400).SendString("Failed creating KPI") + } + return c.SendStatus(204) + } + }) + + // If parent dynamic, create/increment KPI visit and returns 204 + v2.Post("/campaign/:cID/visit/:iID", func(c *fiber.Ctx) error { + cID := c.Params("cID") + campaign, err := analytics.FindCampaignByCID(cID) + if err != nil { + return c.Status(400).SendString("Campaign not found") + } + if campaign.Interact != "Dynamic" { + return c.Status(400).SendString("Cannot create interaction for campaign") + } + iID := c.Params("cID") + "-" + c.Params("iID") + interaction, err := analytics.FindCampaignByCID(iID) + if err == nil { + bufferData(interaction.NotionID, 1, 0) + return c.SendStatus(204) + } else { + err := analytics.CreatePage(iID, cID, campaign.NotionID) + if err != nil { + return c.Status(400).SendString("Failed creating KPI") + } + return c.SendStatus(204) + } + }) + + return v2 +} + +// This is a private bridge function for v1/v2 API endpoints +// since the v2 API endpoints need to return the unformatted +// notion.PageResult instead of the formatted notion.Campaign +func v2FindCampaignByCIDWithoutConvert(cID string) (notion.PageResult, error) { + db, err := notion.FetchDatabase() + if err != nil { + return notion.PageResult{}, err + } + + for _, pageResult := range db.Results { + page := notion.ConvertPageResult(pageResult) + if page.CampaignID == cID { + return pageResult, nil + } + } + + return notion.PageResult{}, errors.New("campaign not found") +} diff --git a/cmd/analytics/v3.go b/cmd/analytics/v3.go new file mode 100644 index 0000000..cd51bf4 --- /dev/null +++ b/cmd/analytics/v3.go @@ -0,0 +1,105 @@ +package main + +import ( + "encoding/json" + + "github.com/gofiber/fiber/v2" + "github.com/ivynya/analytics/internal/notion" + analytics "github.com/ivynya/analytics/pkg" +) + +func createRouterV3(a *fiber.App) fiber.Router { + v3 := a.Group("/v3") + + // Returns a campaign if it is public as a Page + // 0, 0 = visits/interactions (read-only) + v3.Get("/campaign/:cID", func(c *fiber.Ctx) error { + return v3UpdateCampaign(c, 0, 0) + }) + + // Increment campaign visits + // 1, 0 = visits/interactions (+1 visit, 0 interactions) + v3.Post("/campaign/:cID", func(c *fiber.Ctx) error { + return v3UpdateCampaign(c, 1, 0) + }) + + // Increment campaign interactions + // 0, 1 = visits/interactions (0 visits, +1 interaction) + v3.Post("/campaign/:cID/interaction", func(c *fiber.Ctx) error { + return v3UpdateCampaign(c, 0, 1) + }) + + // Returns an KPI if it is public as a Page + // 0, 0 = visits/interactions (read-only) + v3.Get("/campaign/:cID/interaction/:iID", func(c *fiber.Ctx) error { + return v3UpdateInteraction(c, 0, 0) + }) + + // Create/increment KPI interaction + // 0, 1 = visits/interactions (0 visits, +1 interaction) + v3.Post("/campaign/:cID/interaction/:iID", func(c *fiber.Ctx) error { + return v3UpdateInteraction(c, 0, 1) + }) + + // Create/increment KPI visit + // 1, 0 = visits/interactions (+1 visit, 0 interactions) + v3.Post("/campaign/:cID/visit/:iID", func(c *fiber.Ctx) error { + return v3UpdateInteraction(c, 1, 0) + }) + + return v3 +} + +// If the campaign exists, buffer the data and return v3ResponseProtocol +// On error (campaign does not exist), return 400 +func v3UpdateCampaign(c *fiber.Ctx, visits int, interactions int) error { + campaign, err := analytics.FindCampaignByCID(c.Params("cID")) + if err != nil { + return c.Status(400).SendString("Campaign not found") + } + bufferData(campaign.NotionID, visits, interactions) + return v3ResponseProtocol(c, campaign) +} + +// If the interaction exists, buffer the data and return v3ResponseProtocol +// If the interaction does not exist, create it and return 204 +// On error (campaign does not exist, is not Dynamic), return 400 +func v3UpdateInteraction(c *fiber.Ctx, visits int, interactions int) error { + cID := c.Params("cID") + campaign, err := analytics.FindCampaignByCID(cID) + if err != nil { + return c.Status(400).SendString("Campaign does not exist") + } + if campaign.Interact == "Disabled" { + return c.Status(400).SendString("Campaign interactions disabled") + } + iID := c.Params("cID") + "-" + c.Params("iID") + interaction, err := analytics.FindCampaignByCID(iID) + if err == nil { + bufferData(interaction.NotionID, visits, interactions) + return v3ResponseProtocol(c, campaign) + } else { + if campaign.Interact != "Dynamic" { + return c.Status(400).SendString("Campaign dynamic interactions disabled") + } + err := analytics.CreatePage(iID, cID, campaign.NotionID) + if err != nil { + return c.Status(400).SendString("Failed creating KPI") + } + return c.SendStatus(204) + } +} + +// If the campaign is public, return the campaign as JSON (Page) +// If the campaign is not public, return 204 +func v3ResponseProtocol(c *fiber.Ctx, campaign notion.Page) error { + if campaign.Public == "True" { + jso, err := json.Marshal(campaign) + if err != nil { + return c.Status(500).SendString("Internal server error") + } + return c.SendString(string(jso)) + } else { + return c.SendStatus(204) + } +} diff --git a/deps.ts b/deps.ts deleted file mode 100644 index f48cac8..0000000 --- a/deps.ts +++ /dev/null @@ -1,5 +0,0 @@ - -import "https://deno.land/x/dotenv@v3.1.0/load.ts"; - -export { flattenQuery, flattenPage } from "https://deno.land/x/notion_flatten@v0.2.1/mod.ts"; -export { Application, Router } from 'https://deno.land/x/oak@v10.6.0/mod.ts'; \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f141f40 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module github.com/ivynya/analytics + +go 1.20 + +replace github.com/ivynya/analytics/internal => ../internal + +require github.com/gofiber/fiber/v2 v2.48.0 + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/joho/godotenv v1.5.1 + github.com/klauspost/compress v1.16.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.48.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fb86862 --- /dev/null +++ b/go.sum @@ -0,0 +1,97 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/gofiber/fiber/v2 v2.43.0 h1:yit3E4kHf178B60p5CQBa/3v+WVuziWMa/G2ZNyLJB0= +github.com/gofiber/fiber/v2 v2.43.0/go.mod h1:mpS1ZNE5jU+u+BA4FbM+KKnUzJ4wzTK+FT2tG3tU+6I= +github.com/gofiber/fiber/v2 v2.48.0 h1:cRVMCb9aUJDsyHxGFLwz/sGzDggdailZZyptU9F9cU0= +github.com/gofiber/fiber/v2 v2.48.0/go.mod h1:xqJgfqrc23FJuqGOW6DVgi3HyZEm2Mn9pRqUb2kHSX8= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= +github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= +github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= +github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 h1:rmMl4fXJhKMNWl+K+r/fq4FbbKI+Ia2m9hYBLm2h4G4= +github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94/go.mod h1:90zrgN3D/WJsDd1iXHT96alCoN2KJo6/4x1DZC3wZs8= +github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= +github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw= +github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= +github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.45.0 h1:zPkkzpIn8tdHZUrVa6PzYd0i5verqiPSkgTd3bSUcpA= +github.com/valyala/fasthttp v1.45.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= +github.com/valyala/fasthttp v1.48.0 h1:oJWvHb9BIZToTQS3MuQ2R3bJZiNSa2KiNdeI8A+79Tc= +github.com/valyala/fasthttp v1.48.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.4.0 h1:7mTAgkunk3fr4GAloyyCasadO6h9zSsQZbwvcaIciV4= +golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/notion/notion.go b/internal/notion/notion.go new file mode 100644 index 0000000..6aa2334 --- /dev/null +++ b/internal/notion/notion.go @@ -0,0 +1,104 @@ +package notion + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "time" +) + +var ( + databaseCache DatabaseResult + databaseCacheSet time.Time +) + +const CACHE_TIMEOUT = 1 * time.Minute + +func FetchDatabase() (DatabaseResult, error) { + if time.Since(databaseCacheSet) < CACHE_TIMEOUT { + return databaseCache, nil + } + + db_id := os.Getenv("NOTION_DB_ID") + url := "https://api.notion.com/v1/databases/" + db_id + "/query" + client := &http.Client{} + req, _ := http.NewRequest("POST", url, nil) + req.Header.Add("Authorization", "Bearer "+os.Getenv("NOTION_TOKEN")) + req.Header.Add("Notion-Version", "2022-06-28") + + res, err := client.Do(req) + if err != nil { + return DatabaseResult{}, err + } + defer res.Body.Close() + + var j DatabaseResult + err = json.NewDecoder(res.Body).Decode(&j) + if err != nil { + return DatabaseResult{}, err + } + + databaseCache = j + databaseCacheSet = time.Now() + + return j, nil +} + +func CreatePage(body string) error { + url := "https://api.notion.com/v1/pages" + client := &http.Client{} + req, _ := http.NewRequest("POST", url, bytes.NewBuffer([]byte(body))) + req.Header.Add("Authorization", "Bearer "+os.Getenv("NOTION_TOKEN")) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Notion-Version", "2022-06-28") + _, err := client.Do(req) + if err != nil { + return err + } + return nil +} + +func FetchPage(id string) (Page, error) { + url := "https://api.notion.com/v1/pages/" + id + client := &http.Client{} + req, _ := http.NewRequest("GET", url, nil) + req.Header.Add("Authorization", "Bearer "+os.Getenv("NOTION_TOKEN")) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Notion-Version", "2022-06-28") + + res, err := client.Do(req) + if err != nil { + return Page{}, err + } + defer res.Body.Close() + + var j PageResult + err = json.NewDecoder(res.Body).Decode(&j) + if err != nil { + return Page{}, err + } + + return ConvertPageResult(j), nil +} + +func UpdatePage(id string, body string) error { + url := "https://api.notion.com/v1/pages/" + id + client := &http.Client{} + req, _ := http.NewRequest("PATCH", url, bytes.NewBuffer([]byte(body))) + req.Header.Add("Authorization", "Bearer "+os.Getenv("NOTION_TOKEN")) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Notion-Version", "2022-06-28") + + res, err := client.Do(req) + if err != nil { + return err + } + if res.StatusCode != 200 { + return errors.New("notion " + fmt.Sprint(res.StatusCode)) + } + + return nil +} diff --git a/internal/notion/result.go b/internal/notion/result.go new file mode 100644 index 0000000..cb15cae --- /dev/null +++ b/internal/notion/result.go @@ -0,0 +1,27 @@ +package notion + +func ConvertPageResult(p PageResult) Page { + // Get the campaign ID from the result + campaignID := "" + for _, richText := range p.Properties.CampaignID.RichText { + campaignID += richText.PlainText + } + + // Get the parent campaign IDs from the result + parentIDs := make([]string, len(p.Properties.ParentCampaign.Relation)) + for i, campaign := range p.Properties.ParentCampaign.Relation { + parentIDs[i] = campaign.ID + } + + // Return the formatted page object + return Page{ + NotionID: p.ID, + CampaignID: campaignID, + ParentCampaigns: parentIDs, + Interact: p.Properties.Interact.Select.Name, + Public: p.Properties.Public.Select.Name, + Visits: p.Properties.Visits.Number, + RefVisits: p.Properties.RefVisits.Number, + Interactions: p.Properties.Interactions.Number, + } +} diff --git a/internal/notion/types.go b/internal/notion/types.go new file mode 100644 index 0000000..11255f2 --- /dev/null +++ b/internal/notion/types.go @@ -0,0 +1,54 @@ +package notion + +type DatabaseResult struct { + Results []PageResult `json:"results"` + NextCursor string `json:"next_cursor"` + HasMore bool `json:"has_more"` +} + +type PageResult struct { + ID string `json:"id"` + Properties struct { + CampaignID struct { + RichText []struct { + PlainText string `json:"plain_text"` + } `json:"rich_text"` + } `json:"CampaignID"` + RefVisits struct { + Number int `json:"number"` + } `json:"RefVisits"` + Visits struct { + Number int `json:"number"` + } `json:"Visits"` + Interactions struct { + Number int `json:"number"` + } `json:"Interactions"` + ParentCampaign struct { + Relation []struct { + ID string `json:"id"` + } `json:"relation"` + HasMore bool `json:"has_more"` + } `json:"ParentCampaign"` + Interact struct { + Select struct { + Name string `json:"name"` + } `json:"select"` + } `json:"Interact"` + Public struct { + Select struct { + Name string `json:"name"` + } `json:"select"` + } `json:"Public"` + } `json:"properties"` +} + +type Page struct { + NotionID string `json:"id"` + CampaignID string `json:"campaign_id"` + ParentCampaigns []string `json:"parent_campaigns"` + Interact string `json:"interact"` + Public string `json:"public"` + Visits int `json:"visits"` + RefVisits int `json:"ref_visits"` + Interactions int `json:"interactions"` +} diff --git a/main.ts b/main.ts deleted file mode 100644 index eece28b..0000000 --- a/main.ts +++ /dev/null @@ -1,14 +0,0 @@ - -import { Application } from "./deps.ts"; -import { api as api_v1 } from "./api/v1.ts"; -import { api as api_v2 } from "./api/v2.ts"; - -const app = new Application(); - -app.use(api_v1.routes()); -app.use(api_v1.allowedMethods()); -app.use(api_v2.routes()); -app.use(api_v2.allowedMethods()); - -console.log("[EVT] Listening http://localhost:8000"); -await app.listen({ port: 8000 }); \ No newline at end of file diff --git a/notion/createPage.ts b/notion/createPage.ts deleted file mode 100644 index 6fd1f54..0000000 --- a/notion/createPage.ts +++ /dev/null @@ -1,22 +0,0 @@ - -export async function createPage(name: string, parentName: string, parentID: string|undefined) { - return await fetch(`https://api.notion.com/v1/pages`, { - method: "POST", - headers: { - "Authorization": `Bearer ${Deno.env.get("NOTION_TOKEN")}`, - "Content-Type": "application/json", - "Notion-Version": "2022-06-28" - }, - body: JSON.stringify({ - "parent": { "database_id": Deno.env.get("NOTION_DB_ID") }, - "properties": { - "Campaign": { "title": [{ "text": { "content": name } }] }, - "CampaignID": { "rich_text": [{ "text": { "content": parentName + "-" + name } }] }, - "ParentCampaign": parentID ? { "relation": [{ "id": parentID }] } : undefined, - "Visits": { "number": 0 }, - "RefVisits": { "number": 0 }, - "Interactions": { "number": 0 } - } - }) - }); -} \ No newline at end of file diff --git a/notion/getPage.ts b/notion/getPage.ts deleted file mode 100644 index 2dd22d9..0000000 --- a/notion/getPage.ts +++ /dev/null @@ -1,14 +0,0 @@ - -import { flattenPage } from "../deps.ts"; - -export async function getPage(id: string) { - const res = await (await fetch(`https://api.notion.com/v1/pages/${id}`, { - method: "GET", - headers: { - "Authorization": `Bearer ${Deno.env.get("NOTION_TOKEN")}`, - "Notion-Version": "2021-08-16" - } - })).json(); - - return flattenPage(res); -} \ No newline at end of file diff --git a/notion/queryDatabase.ts b/notion/queryDatabase.ts deleted file mode 100644 index 7085b82..0000000 --- a/notion/queryDatabase.ts +++ /dev/null @@ -1,24 +0,0 @@ - -import { flattenQuery } from "../deps.ts"; - -const cachePath = `${Deno.cwd()}/notion/cache.json`; -let lastUpdated = new Date(0); - -export async function queryDatabase(): Promise { - if (lastUpdated.getTime() + 10000 > Date.now()) - return JSON.parse(await Deno.readTextFile(cachePath)); - else lastUpdated = new Date(); - - const id = Deno.env.get("NOTION_DB_ID"); - const res = await (await fetch(`https://api.notion.com/v1/databases/${id}/query`, { - method: "POST", - headers: { - "Authorization": `Bearer ${Deno.env.get("NOTION_TOKEN")}`, - "Notion-Version": "2021-08-16" - } - })).json(); - - const flatRes = flattenQuery(res); - await Deno.writeTextFile(cachePath, JSON.stringify(flatRes)); - return flatRes; -} diff --git a/notion/updatePage.ts b/notion/updatePage.ts deleted file mode 100644 index f96883b..0000000 --- a/notion/updatePage.ts +++ /dev/null @@ -1,63 +0,0 @@ - -import { getPage } from "./getPage.ts"; - -function genVisit(num: number) { - return { "properties": { - "Visits": { "number": num } } - }; -} - -function genRefVisit(num: number, refNum: number) { - return { - "properties": { - "Visits": { "number": num }, - "RefVisits": { "number": refNum } - } - }; -} - -export function updateInteractions(page: any, num: number) { - if (page.ParentCampaign?.length > 0) { - page.ParentCampaign.forEach(async (obj: {id: string}) => { - const parentPage = await getPage(obj.id); - await updateInteractions(parentPage, num); - }) - } - - const i = page.Interactions + num; - return fetch(`https://api.notion.com/v1/pages/${page.id}`, { - body: JSON.stringify({ - "properties": { - "Interactions": { "number": i }, - }, - }), - method: "PATCH", - headers: { - "Authorization": `Bearer ${Deno.env.get("NOTION_TOKEN")}`, - "Content-Type": "application/json", - "Notion-Version": "2021-08-16", - }, - }); -} - - -export function updateVisits(page: any, num: number, ref=false) { - if (page.ParentCampaign?.length > 0) { - page.ParentCampaign.forEach(async (obj: {id: string}) => { - const parentPage = await getPage(obj.id); - await updateVisits(parentPage, num, true); - }) - } - - const v = page.Visits + num; - const rV = page.RefVisits + num; - return fetch(`https://api.notion.com/v1/pages/${page.id}`, { - body: JSON.stringify(ref ? genRefVisit(v, rV) : genVisit(v)), - method: "PATCH", - headers: { - "Authorization": `Bearer ${Deno.env.get("NOTION_TOKEN")}`, - "Content-Type": "application/json", - "Notion-Version": "2021-08-16" - } - }); -} \ No newline at end of file diff --git a/pkg/create_kpi.go b/pkg/create_kpi.go new file mode 100644 index 0000000..3179fd9 --- /dev/null +++ b/pkg/create_kpi.go @@ -0,0 +1,28 @@ +package analytics + +import ( + "fmt" + "os" + + "github.com/ivynya/analytics/internal/notion" +) + +// Create a new campaign page in Notion given CampaignID +// and ParentCampaignID with parent campaign's NotionID +func CreatePage(cID string, parentCID string, parentNID string) error { + body := fmt.Sprintf(` + { + "parent": { "database_id": "%s" }, + "properties": { + "Campaign": { "title": [{ "text": { "content": %s } }] }, + "CampaignID": { "rich_text": [{ "text": { "content": %s-%s } }] }, + "ParentCampaign": { "relation": [{ "id": { "content": "%s" } }] }, + "Visits": { "number": 0 }, + "RefVisits": { "number": 0 }, + "Interactions": { "number": 0 } + } + } + `, os.Getenv("NOTION_DB_ID"), cID, parentCID, cID, parentNID) + + return notion.CreatePage(body) +} diff --git a/pkg/find_campaign.go b/pkg/find_campaign.go new file mode 100644 index 0000000..5e83a93 --- /dev/null +++ b/pkg/find_campaign.go @@ -0,0 +1,41 @@ +package analytics + +import ( + "errors" + + "github.com/ivynya/analytics/internal/notion" +) + +// Find a campaign by its CampaignID property +func FindCampaignByCID(cID string) (notion.Page, error) { + db, err := notion.FetchDatabase() + if err != nil { + return notion.Page{}, err + } + + for _, pageResult := range db.Results { + page := notion.ConvertPageResult(pageResult) + if page.CampaignID == cID { + return page, nil + } + } + + return notion.Page{}, errors.New("campaign not found") +} + +// Find a campaign by its NotionID/raw ID property +func FindCampaignByNID(nID string) (notion.Page, error) { + db, err := notion.FetchDatabase() + if err != nil { + return notion.Page{}, err + } + + for _, pageResult := range db.Results { + page := notion.ConvertPageResult(pageResult) + if page.NotionID == nID { + return page, nil + } + } + + return notion.Page{}, errors.New("campaign not found") +} diff --git a/pkg/update_interactions.go b/pkg/update_interactions.go new file mode 100644 index 0000000..bf71305 --- /dev/null +++ b/pkg/update_interactions.go @@ -0,0 +1,37 @@ +package analytics + +import ( + "fmt" + + "github.com/ivynya/analytics/internal/notion" +) + +// Update interactions of a campaign, along with any parent campaigns +func UpdateInteractions(nID string, num int) error { + campaign, err := notion.FetchPage(nID) + if err != nil { + return err + } + + if len(campaign.ParentCampaigns) > 0 { + for _, parentNID := range campaign.ParentCampaigns { + err := UpdateInteractions(parentNID, num) + if err != nil { + return err + } + } + } + + v := campaign.Interactions + num + bodyString := fmt.Sprintf(` + { "properties": { + "Interactions": { "number": %d } + } }`, v) + + err = notion.UpdatePage(nID, bodyString) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/update_visits.go b/pkg/update_visits.go new file mode 100644 index 0000000..c77646c --- /dev/null +++ b/pkg/update_visits.go @@ -0,0 +1,43 @@ +package analytics + +import ( + "fmt" + + "github.com/ivynya/analytics/internal/notion" +) + +// Update vists of a campaign, along with any parent campaigns +// If ref is true, will update ref visits of the campaign +func UpdateVisits(nID string, num int, ref bool) error { + campaign, err := notion.FetchPage(nID) + if err != nil { + return err + } + + if len(campaign.ParentCampaigns) > 0 { + for _, parentNID := range campaign.ParentCampaigns { + err := UpdateVisits(parentNID, num, true) + if err != nil { + return err + } + } + } + + v := campaign.Visits + num + r := campaign.RefVisits + if ref { + r += num + } + bodyString := fmt.Sprintf(` + { "properties": { + "Visits": { "number": %d }, + "RefVisits": { "number": %d } + } }`, v, r) + + err = notion.UpdatePage(nID, bodyString) + if err != nil { + return err + } + + return nil +} diff --git a/schema/buffer.ts b/schema/buffer.ts deleted file mode 100644 index fc94f4b..0000000 --- a/schema/buffer.ts +++ /dev/null @@ -1,7 +0,0 @@ - -export interface Buffer { - [id: string]: { - visits: number - interactions: number - } -} \ No newline at end of file