├── .devcontainer └── devcontainer.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ ├── feature_request.md │ └── question.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── auto-assign.yml │ ├── ci.yml │ ├── deploy-docs.yml │ ├── docker.yml │ ├── release.yml │ └── update-contributors.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .npmrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── CONTRIBUTORS.svg ├── LICENSE ├── README.md ├── apps ├── api │ ├── .dockerignore │ ├── Dockerfile │ ├── drizzle.config.ts │ ├── drizzle │ │ ├── 0000_deep_spot.sql │ │ ├── 0001_blue_unicorn.sql │ │ ├── 0002_many_lake.sql │ │ ├── 0003_daffy_amazoness.sql │ │ ├── 0004_small_dragon_lord.sql │ │ ├── 0005_faulty_xorn.sql │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ ├── 0001_snapshot.json │ │ │ ├── 0002_snapshot.json │ │ │ ├── 0003_snapshot.json │ │ │ ├── 0004_snapshot.json │ │ │ ├── 0005_snapshot.json │ │ │ └── _journal.json │ ├── package.json │ ├── src │ │ ├── activity │ │ │ ├── controllers │ │ │ │ ├── create-activity.ts │ │ │ │ ├── create-comment.ts │ │ │ │ ├── delete-comment.ts │ │ │ │ ├── get-activities.ts │ │ │ │ └── update-comment.ts │ │ │ └── index.ts │ │ ├── database │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── events │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── label │ │ │ ├── controllers │ │ │ │ ├── create-label.ts │ │ │ │ ├── delete-label.ts │ │ │ │ ├── get-label.ts │ │ │ │ ├── get-labels-by-task-id.ts │ │ │ │ └── update-label.ts │ │ │ └── index.ts │ │ ├── middlewares │ │ │ └── auth.ts │ │ ├── notification │ │ │ ├── controllers │ │ │ │ ├── clear-notifications.ts │ │ │ │ ├── create-notification.ts │ │ │ │ ├── get-notifications.ts │ │ │ │ ├── mark-all-notifications-as-read.ts │ │ │ │ └── mark-notification-as-read.ts │ │ │ └── index.ts │ │ ├── project │ │ │ ├── controllers │ │ │ │ ├── create-project.ts │ │ │ │ ├── delete-project.ts │ │ │ │ ├── get-project.ts │ │ │ │ ├── get-projects.ts │ │ │ │ └── update-project.ts │ │ │ └── index.ts │ │ ├── task │ │ │ ├── controllers │ │ │ │ ├── create-task.ts │ │ │ │ ├── delete-task.ts │ │ │ │ ├── export-tasks.ts │ │ │ │ ├── get-next-task-number.ts │ │ │ │ ├── get-task.ts │ │ │ │ ├── get-tasks.ts │ │ │ │ ├── import-tasks.ts │ │ │ │ ├── update-task-status.ts │ │ │ │ └── update-task.ts │ │ │ └── index.ts │ │ ├── time-entry │ │ │ ├── controllers │ │ │ │ ├── create-time-entry.ts │ │ │ │ ├── get-time-entries.ts │ │ │ │ ├── get-time-entry.ts │ │ │ │ └── update-time-entry.ts │ │ │ └── index.ts │ │ ├── user │ │ │ ├── controllers │ │ │ │ ├── sign-in.ts │ │ │ │ └── sign-up.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ │ ├── create-session.ts │ │ │ │ ├── generate-session-token.ts │ │ │ │ ├── invalidate-session.ts │ │ │ │ ├── is-in-secure-mode.ts │ │ │ │ └── validate-session-token.ts │ │ ├── utils │ │ │ ├── create-demo-user.ts │ │ │ ├── generate-demo-name.ts │ │ │ ├── get-settings.ts │ │ │ ├── purge-demo-data.ts │ │ │ └── set-demo-user.ts │ │ ├── workspace-user │ │ │ ├── controllers │ │ │ │ ├── create-root-workspace-user.ts │ │ │ │ ├── delete-workspace-user.ts │ │ │ │ ├── get-active-workspace-users.ts │ │ │ │ ├── get-workspace-user.ts │ │ │ │ ├── get-workspace-users.ts │ │ │ │ ├── invite-workspace-user.ts │ │ │ │ └── update-workspace-user.ts │ │ │ └── index.ts │ │ └── workspace │ │ │ ├── controllers │ │ │ ├── create-workspace.ts │ │ │ ├── delete-workspace.ts │ │ │ ├── get-workspace.ts │ │ │ ├── get-workspaces.ts │ │ │ └── update-workspace.ts │ │ │ └── index.ts │ └── tsconfig.json ├── docs │ ├── app │ │ ├── (home) │ │ │ ├── layout.tsx │ │ │ ├── opengraph-image.png │ │ │ └── page.tsx │ │ ├── api │ │ │ └── search │ │ │ │ └── route.ts │ │ ├── docs-og │ │ │ └── [...slug] │ │ │ │ └── route.ts │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── global.css │ │ ├── icon.tsx │ │ ├── layout.config.tsx │ │ ├── layout.tsx │ │ └── sitemap.ts │ ├── components │ │ ├── community.tsx │ │ ├── features.tsx │ │ ├── footer.tsx │ │ ├── hero.tsx │ │ ├── laptop-animation.tsx │ │ ├── laptop-container.tsx │ │ └── stats.tsx │ ├── content │ │ └── docs │ │ │ ├── deployments │ │ │ ├── kubernetes.mdx │ │ │ ├── meta.json │ │ │ ├── nginx.mdx │ │ │ └── traefik.mdx │ │ │ ├── index.mdx │ │ │ ├── roadmap.mdx │ │ │ └── terminology │ │ │ ├── index.mdx │ │ │ ├── meta.json │ │ │ ├── projects.mdx │ │ │ ├── tasks.mdx │ │ │ ├── teams.mdx │ │ │ └── workspaces.mdx │ ├── lib │ │ ├── Inter_18pt-Bold.ttf │ │ ├── Inter_18pt-Regular.ttf │ │ ├── og.tsx │ │ └── source.ts │ ├── mdx-components.tsx │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ │ └── robots.txt │ ├── source.config.ts │ └── tsconfig.json └── web │ ├── .dockerignore │ ├── .env.production │ ├── Dockerfile │ ├── components.json │ ├── env.sh │ ├── index.html │ ├── nginx.conf │ ├── package.json │ ├── postcss.config.js │ ├── public │ ├── android-icon-144x144.png │ ├── android-icon-192x192.png │ ├── android-icon-36x36.png │ ├── android-icon-48x48.png │ ├── android-icon-72x72.png │ ├── android-icon-96x96.png │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── apple-icon-precomposed.png │ ├── apple-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── favicon.ico │ ├── manifest.json │ ├── ms-icon-144x144.png │ ├── ms-icon-150x150.png │ ├── ms-icon-310x310.png │ └── ms-icon-70x70.png │ ├── src │ ├── components │ │ ├── auth │ │ │ ├── layout.tsx │ │ │ ├── sign-in-form.tsx │ │ │ ├── sign-up-form.tsx │ │ │ └── toggle.tsx │ │ ├── backlog-list-view │ │ │ ├── backlog-task-row.tsx │ │ │ └── index.tsx │ │ ├── command-palette │ │ │ ├── command-group.tsx │ │ │ └── index.tsx │ │ ├── common │ │ │ ├── editor.tsx │ │ │ ├── logo.tsx │ │ │ ├── sidebar │ │ │ │ ├── index.tsx │ │ │ │ ├── sections │ │ │ │ │ ├── manage-teams │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── projects │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── user-info.tsx │ │ │ │ │ ├── workspace-settings │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── workspaces │ │ │ │ │ │ ├── components │ │ │ │ │ │ ├── add-workspace.tsx │ │ │ │ │ │ └── workspace-picker.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ ├── sidebar-content.tsx │ │ │ │ ├── sidebar-footer.tsx │ │ │ │ └── sidebar-header.tsx │ │ │ └── tip.tsx │ │ ├── dashboard │ │ │ └── empty-state.tsx │ │ ├── demo-alert.tsx │ │ ├── filters │ │ │ └── index.tsx │ │ ├── kanban-board │ │ │ ├── column │ │ │ │ ├── column-dropzone.tsx │ │ │ │ ├── column-footer.tsx │ │ │ │ ├── column-header.tsx │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ ├── task-card-context-menu │ │ │ │ └── task-card-context-menu-content.tsx │ │ │ ├── task-card.tsx │ │ │ └── task-labels.tsx │ │ ├── list-view │ │ │ ├── index.tsx │ │ │ ├── task-row-overlay.tsx │ │ │ └── task-row.tsx │ │ ├── notification │ │ │ ├── notification-bell.tsx │ │ │ └── notification-item.tsx │ │ ├── page-title.tsx │ │ ├── project │ │ │ ├── empty-state.tsx │ │ │ ├── select-project-state.tsx │ │ │ └── tasks-import-export.tsx │ │ ├── providers │ │ │ ├── auth-provider │ │ │ │ ├── hooks │ │ │ │ │ └── use-auth.ts │ │ │ │ └── index.tsx │ │ │ └── theme-provider │ │ │ │ ├── hooks │ │ │ │ └── use-theme.tsx │ │ │ │ └── index.tsx │ │ ├── shared │ │ │ └── modals │ │ │ │ ├── create-project-modal.tsx │ │ │ │ ├── create-task-modal.tsx │ │ │ │ └── create-workspace-modal.tsx │ │ ├── task │ │ │ ├── task-activities.tsx │ │ │ ├── task-calendar.tsx │ │ │ ├── task-comment.tsx │ │ │ ├── task-description.tsx │ │ │ ├── task-info.tsx │ │ │ ├── task-labels.tsx │ │ │ ├── task-time-tracking.tsx │ │ │ └── task-title.tsx │ │ ├── team │ │ │ ├── delete-team-member-modal.tsx │ │ │ ├── invite-team-member-modal.tsx │ │ │ └── members-table.tsx │ │ ├── ui │ │ │ ├── alert.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── dialog.tsx │ │ │ ├── form.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── popover.tsx │ │ │ ├── select.tsx │ │ │ ├── spinner.tsx │ │ │ ├── switch.tsx │ │ │ └── tooltip.tsx │ │ └── workspace │ │ │ ├── empty-state.tsx │ │ │ └── select-workspace-state.tsx │ ├── constants │ │ ├── priority-colors.ts │ │ ├── project-icons.ts │ │ └── urls.ts │ ├── fetchers │ │ ├── activity │ │ │ ├── create-activity.ts │ │ │ └── get-activites-by-task-id.ts │ │ ├── comment │ │ │ ├── create-comment.ts │ │ │ ├── delete-comment.ts │ │ │ └── update-comment.ts │ │ ├── label │ │ │ ├── create-label.ts │ │ │ ├── delete-label.ts │ │ │ ├── get-labels-by-task.ts │ │ │ └── update-label.ts │ │ ├── notification │ │ │ ├── clear-notifications.ts │ │ │ ├── get-notifications.ts │ │ │ ├── mark-all-notifications-as-read.ts │ │ │ └── mark-notification-as-read.ts │ │ ├── project │ │ │ ├── create-project.ts │ │ │ ├── delete-project.ts │ │ │ ├── get-project.ts │ │ │ ├── get-projects.ts │ │ │ └── update-project.ts │ │ ├── task │ │ │ ├── create-task.ts │ │ │ ├── delete-task.ts │ │ │ ├── export-tasks.ts │ │ │ ├── get-task.ts │ │ │ ├── get-tasks.ts │ │ │ ├── import-tasks.ts │ │ │ └── update-task.ts │ │ ├── time-entry │ │ │ ├── create-time-entry.ts │ │ │ ├── get-time-entries.ts │ │ │ └── update-time-entry.ts │ │ ├── user │ │ │ ├── me.ts │ │ │ ├── sign-in.ts │ │ │ ├── sign-out.ts │ │ │ └── sign-up.ts │ │ ├── workspace-user │ │ │ ├── delete-workspace-user.ts │ │ │ ├── get-active-workspace-users.ts │ │ │ ├── get-workspace-users.ts │ │ │ └── invite-workspace-member.ts │ │ └── workspace │ │ │ ├── create-workspace.ts │ │ │ ├── delete-workspace.ts │ │ │ ├── get-workspace.ts │ │ │ ├── get-workspaces.ts │ │ │ └── update-workspace.ts │ ├── hooks │ │ ├── mutations │ │ │ ├── comment │ │ │ │ ├── use-create-comment.ts │ │ │ │ ├── use-delete-comment.ts │ │ │ │ └── use-update-comment.ts │ │ │ ├── label │ │ │ │ ├── use-create-label.ts │ │ │ │ ├── use-delete-label.ts │ │ │ │ └── use-update-label.ts │ │ │ ├── notification │ │ │ │ ├── use-clear-notifications.ts │ │ │ │ ├── use-mark-all-notifications-as-read.ts │ │ │ │ └── use-mark-notification-as-read.ts │ │ │ ├── project │ │ │ │ ├── use-create-project.ts │ │ │ │ ├── use-delete-project.ts │ │ │ │ └── use-update-project.ts │ │ │ ├── task │ │ │ │ ├── use-create-task.ts │ │ │ │ ├── use-delete-task.ts │ │ │ │ ├── use-export-tasks.ts │ │ │ │ ├── use-import-tasks.ts │ │ │ │ └── use-update-task.ts │ │ │ ├── time-entry │ │ │ │ ├── use-create-time-entry.ts │ │ │ │ └── use-update-time-entry.ts │ │ │ ├── use-sign-in.ts │ │ │ ├── use-sign-out.ts │ │ │ ├── use-sign-up.ts │ │ │ ├── workspace-user │ │ │ │ ├── use-delete-workspace-user.ts │ │ │ │ └── use-invite-workspace-user.ts │ │ │ └── workspace │ │ │ │ ├── use-delete-workspace.ts │ │ │ │ └── use-update-workspace.ts │ │ ├── queries │ │ │ ├── activity │ │ │ │ └── use-get-activities-by-task-id.ts │ │ │ ├── label │ │ │ │ └── use-get-labels-by-task.ts │ │ │ ├── notification │ │ │ │ └── use-get-notifications.ts │ │ │ ├── project │ │ │ │ ├── use-get-project.ts │ │ │ │ └── use-get-projects.ts │ │ │ ├── task │ │ │ │ ├── use-get-task.ts │ │ │ │ └── use-get-tasks.ts │ │ │ ├── time-entry │ │ │ │ └── use-get-time-entries.ts │ │ │ ├── use-get-me.ts │ │ │ ├── workspace-users │ │ │ │ ├── use-active-workspace-users.ts │ │ │ │ └── use-get-workspace-users.ts │ │ │ └── workspace │ │ │ │ ├── use-create-workspace.ts │ │ │ │ ├── use-get-workspace.ts │ │ │ │ └── use-get-workspaces.ts │ │ └── useWorkspacePermission.ts │ ├── index.css │ ├── lib │ │ ├── cn.ts │ │ ├── debounce.ts │ │ ├── format-duration.ts │ │ ├── generate-link.ts │ │ ├── generate-project-id.ts │ │ ├── status.tsx │ │ └── to-kebab-case.ts │ ├── main.tsx │ ├── query-client │ │ └── index.ts │ ├── routeTree.gen.ts │ ├── routes │ │ ├── __root.tsx │ │ ├── auth │ │ │ ├── sign-in.tsx │ │ │ └── sign-up.tsx │ │ ├── dashboard.tsx │ │ ├── dashboard │ │ │ ├── settings.tsx │ │ │ ├── settings │ │ │ │ └── appearance.tsx │ │ │ ├── teams │ │ │ │ └── $workspaceId │ │ │ │ │ ├── _layout.members.tsx │ │ │ │ │ ├── _layout.roles.tsx │ │ │ │ │ └── _layout.tsx │ │ │ ├── workspace-settings │ │ │ │ └── $workspaceId │ │ │ │ │ └── index.tsx │ │ │ └── workspace │ │ │ │ ├── $workspaceId.tsx │ │ │ │ └── $workspaceId │ │ │ │ └── project │ │ │ │ ├── $projectId.tsx │ │ │ │ └── $projectId │ │ │ │ ├── backlog.tsx │ │ │ │ ├── board.tsx │ │ │ │ ├── settings.tsx │ │ │ │ └── task │ │ │ │ └── $taskId.tsx │ │ └── index.tsx │ ├── store │ │ ├── project.ts │ │ ├── user-preferences.ts │ │ └── workspace.ts │ ├── tanstack │ │ └── router.tsx │ ├── types │ │ ├── api-response.ts │ │ ├── notification.ts │ │ ├── project │ │ │ └── index.ts │ │ ├── task │ │ │ └── index.ts │ │ ├── time-entry │ │ │ └── index.ts │ │ ├── user.ts │ │ ├── workspace-user │ │ │ └── index.ts │ │ └── workspace │ │ │ └── index.ts │ └── vite-env.d.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── biome.json ├── charts └── kaneo │ ├── Chart.yaml │ ├── README.md │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── deployment.yaml │ ├── hpa.yaml │ ├── ingress.yaml │ ├── pvc.yaml │ ├── serviceaccount.yaml │ └── services.yaml │ └── values.yaml ├── commitlint.config.js ├── compose.demo.yml ├── compose.local.yml ├── package.json ├── packages ├── libs │ ├── package.json │ ├── src │ │ ├── hono.ts │ │ └── index.ts │ └── tsconfig.json └── typescript-config │ ├── base.json │ ├── package.json │ └── react-library.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm", 7 | "features": { 8 | "ghcr.io/devcontainers/features/git:1": {}, 9 | "ghcr.io/shyim/devcontainers-features/bun:0": {} 10 | } 11 | 12 | // Features to add to the dev container. More info: https://containers.dev/features. 13 | // "features": {}, 14 | 15 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 16 | // "forwardPorts": [], 17 | 18 | // Use 'postCreateCommand' to run commands after the container is created. 19 | // "postCreateCommand": "yarn install", 20 | 21 | // Configure tool-specific properties. 22 | // "customizations": {}, 23 | 24 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 25 | // "remoteUser": "root" 26 | } 27 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [usekaneo, aacevski] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve Kaneo 4 | title: 'bug: ' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | ## Bug Description 10 | A clear and concise description of what the bug is. 11 | 12 | ## Steps To Reproduce 13 | 1. Go to '...' 14 | 2. Click on '....' 15 | 3. Scroll down to '....' 16 | 4. See error 17 | 18 | ## Expected Behavior 19 | A clear and concise description of what you expected to happen. 20 | 21 | ## Screenshots 22 | If applicable, add screenshots to help explain your problem. 23 | 24 | ## Environment 25 | - Kaneo Version: [e.g., 1.0.0] 26 | - Deployment Method: [e.g., Docker, Kubernetes, etc.] 27 | - Browser: [e.g., Chrome 120, Firefox 119] 28 | - OS: [e.g., Windows 11, macOS Sonoma, Ubuntu 22.04] 29 | 30 | ## Additional Context 31 | Add any other context about the problem here. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Kaneo Documentation 4 | url: https://kaneo.app/docs 5 | about: Check our documentation for answers to common questions 6 | - name: Kaneo Discord Community 7 | url: https://discord.gg/rU4tSyhXXU 8 | about: Join our Discord server to chat with the community and get help 9 | - name: GitHub Discussions 10 | url: https://github.com/orgs/usekaneo/discussions 11 | about: For general questions and discussions about Kaneo -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for Kaneo 4 | title: 'feat: ' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | ## Problem Statement 10 | A clear and concise description of what problem this feature would solve. 11 | Example: I'm always frustrated when [...] 12 | 13 | ## Proposed Solution 14 | A clear and concise description of what you want to happen. 15 | 16 | ## Alternative Solutions 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | ## Relevant Context 20 | Add any other context, screenshots, or examples about the feature request here. 21 | 22 | ## Does this feature align with Kaneo's focus on simplicity? 23 | Yes/No, because... -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question or Help 3 | about: Ask a question or request help with Kaneo 4 | title: 'question: ' 5 | labels: question 6 | assignees: '' 7 | --- 8 | 9 | ## Question 10 | A clear and concise description of your question. 11 | 12 | ## What I've Tried 13 | Steps you've already taken to find an answer: 14 | 1. Checked documentation at... 15 | 2. Searched for similar issues... 16 | 3. Tried to... 17 | 18 | ## Environment (if relevant) 19 | - Kaneo Version: [e.g., 1.0.0] 20 | - Deployment Method: [e.g., Docker, Kubernetes, etc.] 21 | - Browser: [e.g., Chrome, Firefox] 22 | - OS: [e.g., Windows, macOS, Linux] 23 | 24 | ## Additional Context 25 | Add any other context or screenshots about your question here. -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | ## Related Issue(s) 5 | 6 | Fixes # 7 | 8 | ## Type of Change 9 | 10 | - [ ] Bug fix (non-breaking change that fixes an issue) 11 | - [ ] New feature (non-breaking change that adds functionality) 12 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 13 | - [ ] Documentation update 14 | - [ ] Refactoring (no functional changes) 15 | - [ ] Performance improvement 16 | - [ ] Test addition or update 17 | - [ ] Other (please describe): 18 | 19 | ## How Has This Been Tested? 20 | - [ ] Unit tests 21 | - [ ] Integration tests 22 | - [ ] Manual testing 23 | - [ ] Other (please describe): 24 | 25 | ## Screenshots (if applicable) 26 | 27 | ## Checklist 28 | 29 | - [ ] My code follows the style guidelines of this project 30 | - [ ] I have performed a self-review of my own code 31 | - [ ] I have commented my code, particularly in hard-to-understand areas 32 | - [ ] I have made corresponding changes to the documentation 33 | - [ ] My changes generate no new warnings 34 | - [ ] I have added tests that prove my fix is effective or that my feature works 35 | - [ ] New and existing unit tests pass locally with my changes 36 | - [ ] Any dependent changes have been merged and published 37 | 38 | ## Additional Notes 39 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/apps/api" 5 | schedule: 6 | interval: "weekly" 7 | 8 | - package-ecosystem: "npm" 9 | directory: "/apps/web" 10 | schedule: 11 | interval: "weekly" 12 | 13 | - package-ecosystem: "npm" 14 | directory: "/packages/libs" 15 | schedule: 16 | interval: "weekly" 17 | -------------------------------------------------------------------------------- /.github/workflows/auto-assign.yml: -------------------------------------------------------------------------------- 1 | name: Auto Assign 2 | on: 3 | issues: 4 | types: [opened] 5 | pull_request_target: 6 | types: [opened] 7 | jobs: 8 | run: 9 | runs-on: ubuntu-24.04 10 | permissions: 11 | issues: write 12 | pull-requests: write 13 | steps: 14 | - name: 'Auto-assign issue' 15 | uses: pozil/auto-assign-issue@e0a56afd8846954587b00fff254caf1ec918554e 16 | with: 17 | repo-token: ${{ secrets.GITHUB_TOKEN }} 18 | assignees: aacevski 19 | numOfAssignee: 1 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Code quality 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | quality: 8 | runs-on: ubuntu-24.04 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | - name: Setup Biome 13 | uses: biomejs/setup-biome@v2 14 | with: 15 | version: latest 16 | - name: Run Biome 17 | run: biome ci . 18 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Documentation Site 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | paths: 7 | - 'apps/docs/**' 8 | - '.github/workflows/deploy-docs.yml' 9 | 10 | workflow_dispatch: 11 | 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | concurrency: 18 | group: "pages" 19 | cancel-in-progress: false 20 | 21 | jobs: 22 | build: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | 28 | - name: Setup pnpm 29 | uses: pnpm/action-setup@v3 30 | with: 31 | version: 10.7.0 32 | 33 | - name: Setup Node 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: "20" 37 | cache: 'pnpm' 38 | 39 | - name: Setup Pages 40 | uses: actions/configure-pages@v5 41 | with: 42 | static_site_generator: next 43 | 44 | - name: Install dependencies 45 | run: pnpm install --frozen-lockfile 46 | 47 | - name: Build documentation site 48 | working-directory: apps/docs 49 | run: pnpm run build 50 | 51 | - name: Upload artifact 52 | uses: actions/upload-pages-artifact@v3 53 | with: 54 | path: ./apps/docs/out 55 | 56 | deploy: 57 | environment: 58 | name: github-pages 59 | url: ${{ steps.deployment.outputs.page_url }} 60 | runs-on: ubuntu-latest 61 | needs: build 62 | steps: 63 | - name: Deploy to GitHub Pages 64 | id: deployment 65 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /.github/workflows/update-contributors.yml: -------------------------------------------------------------------------------- 1 | name: Update Contributors 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 0 * * 0' 7 | push: 8 | branches: 9 | - main 10 | paths: 11 | - '.github/FUNDING.yml' 12 | 13 | jobs: 14 | update-contributors: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: write 18 | pull-requests: write 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Generate Contributors Images 25 | uses: jaywcjlove/github-action-contributors@main 26 | with: 27 | token: ${{ secrets.GITHUB_TOKEN }} 28 | filter-author: (renovate\[bot\]|renovate-bot|dependabot\[bot\]) 29 | output: CONTRIBUTORS.svg 30 | avatarSize: 42 31 | 32 | - name: Generate Sponsors Section 33 | if: ${{ github.repository_owner == 'usekaneo' }} 34 | uses: JamesIves/github-sponsors-readme-action@v1 35 | with: 36 | token: ${{ secrets.GH_ANDREJ }} 37 | file: 'README.md' 38 | minimum: 1 39 | marker: sponsors 40 | 41 | - name: Commit and Push Changes 42 | run: | 43 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 44 | git config --local user.name "github-actions[bot]" 45 | git add CONTRIBUTORS.svg README.md 46 | git commit -m "docs: update contributors and sponsors" || exit 0 47 | git push -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # Local env files 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | # Testing 16 | coverage 17 | 18 | # Turbo 19 | .turbo 20 | 21 | # Vercel 22 | .vercel 23 | 24 | # Build Outputs 25 | .next/ 26 | out/ 27 | build 28 | dist 29 | .source 30 | 31 | # Debug 32 | npm-debug.log* 33 | yarn-debug.log* 34 | yarn-error.log* 35 | 36 | # Misc 37 | .DS_Store 38 | *.pem 39 | 40 | # DB 41 | *.db -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | bun x commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm dlx @biomejs/biome ci . 2 | pnpm run build -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usekaneo/kaneo/a4339d9f9efa2bf35845c8bd7c5aa519cf1d6f57/.npmrc -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.defaultFormatter": "biomejs.biome", 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.biome": "explicit", 7 | "source.organizeImports.biome": "explicit" 8 | }, 9 | "[javascript]": { 10 | "editor.defaultFormatter": "biomejs.biome", 11 | "editor.formatOnSave": true 12 | }, 13 | "[typescript]": { 14 | "editor.defaultFormatter": "biomejs.biome", 15 | "editor.formatOnSave": true 16 | }, 17 | "[javascriptreact]": { 18 | "editor.defaultFormatter": "biomejs.biome", 19 | "editor.formatOnSave": true 20 | }, 21 | "[typescriptreact]": { 22 | "editor.defaultFormatter": "biomejs.biome", 23 | "editor.formatOnSave": true 24 | }, 25 | "prettier.enable": false, 26 | "editor.formatOnSaveMode": "file", 27 | "[jsonc]": { 28 | "editor.defaultFormatter": "biomejs.biome" 29 | }, 30 | "[json]": { 31 | "editor.defaultFormatter": "biomejs.biome" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Andrej Acevski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apps/api/.dockerignore: -------------------------------------------------------------------------------- 1 | # Version control 2 | .git 3 | .gitignore 4 | 5 | # Build artifacts 6 | node_modules 7 | dist 8 | build 9 | *.log 10 | 11 | # Development files 12 | .env 13 | .env.* 14 | !.env.production 15 | *.local 16 | 17 | # Editor directories and files 18 | .vscode 19 | .idea 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Testing 27 | coverage 28 | *.test.ts 29 | *.spec.ts 30 | test 31 | 32 | # Documentation 33 | docs 34 | *.md 35 | 36 | # Temporary files 37 | .DS_Store 38 | Thumbs.db 39 | -------------------------------------------------------------------------------- /apps/api/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { type Config, defineConfig } from "drizzle-kit"; 2 | 3 | export default defineConfig({ 4 | out: "./drizzle", 5 | schema: "./src/database/schema.ts", 6 | dialect: "sqlite", 7 | dbCredentials: { 8 | url: "file:kaneo.db", 9 | }, 10 | }) satisfies Config; 11 | -------------------------------------------------------------------------------- /apps/api/drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1744923301762, 9 | "tag": "0000_deep_spot", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "6", 15 | "when": 1745161578587, 16 | "tag": "0001_blue_unicorn", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "6", 22 | "when": 1745162073413, 23 | "tag": "0002_many_lake", 24 | "breakpoints": true 25 | }, 26 | { 27 | "idx": 3, 28 | "version": "6", 29 | "when": 1745270042523, 30 | "tag": "0003_daffy_amazoness", 31 | "breakpoints": true 32 | }, 33 | { 34 | "idx": 4, 35 | "version": "6", 36 | "when": 1746066911796, 37 | "tag": "0004_small_dragon_lord", 38 | "breakpoints": true 39 | }, 40 | { 41 | "idx": 5, 42 | "version": "6", 43 | "when": 1746464905156, 44 | "tag": "0005_faulty_xorn", 45 | "breakpoints": true 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /apps/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kaneo/api", 3 | "type": "commonjs", 4 | "main": "./src/index.ts", 5 | "scripts": { 6 | "dev": "tsx watch src/index.ts", 7 | "build": "esbuild src/index.ts --bundle --platform=node --outdir=dist --format=cjs --external:better-sqlite3 --external:bcrypt --external:mock-aws-s3 --external:aws-sdk --external:nock" 8 | }, 9 | "dependencies": { 10 | "@hono/node-server": "^1.14.1", 11 | "@hono/zod-validator": "^0.5.0", 12 | "@oslojs/crypto": "^1.0.1", 13 | "@oslojs/encoding": "^1.1.0", 14 | "@paralleldrive/cuid2": "^2.2.2", 15 | "bcrypt": "^6.0.0", 16 | "better-sqlite3": "^11.10.0", 17 | "dotenv": "^16.5.0", 18 | "drizzle-kit": "^0.31.1", 19 | "drizzle-orm": "^0.43.0", 20 | "hono": "^4.7.9", 21 | "zod": "^3.24.4" 22 | }, 23 | "devDependencies": { 24 | "@types/bcrypt": "^5.0.2", 25 | "@types/better-sqlite3": "^7.6.13", 26 | "@types/node": "^22.15.17", 27 | "esbuild": "^0.25.4", 28 | "tsx": "^4.19.4", 29 | "typescript": "^5.8.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /apps/api/src/activity/controllers/create-activity.ts: -------------------------------------------------------------------------------- 1 | import db from "../../database"; 2 | import { activityTable } from "../../database/schema"; 3 | 4 | async function createActivity( 5 | taskId: string, 6 | type: string, 7 | userEmail: string, 8 | content: string, 9 | ) { 10 | const activity = await db.insert(activityTable).values({ 11 | taskId, 12 | type, 13 | userEmail, 14 | content, 15 | }); 16 | return activity; 17 | } 18 | 19 | export default createActivity; 20 | -------------------------------------------------------------------------------- /apps/api/src/activity/controllers/create-comment.ts: -------------------------------------------------------------------------------- 1 | import db from "../../database"; 2 | import { activityTable } from "../../database/schema"; 3 | 4 | async function createComment( 5 | taskId: string, 6 | userEmail: string, 7 | content: string, 8 | ) { 9 | const activity = await db.insert(activityTable).values({ 10 | taskId, 11 | type: "comment", 12 | userEmail, 13 | content, 14 | }); 15 | return activity; 16 | } 17 | 18 | export default createComment; 19 | -------------------------------------------------------------------------------- /apps/api/src/activity/controllers/delete-comment.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { activityTable } from "../../database/schema"; 4 | 5 | async function deleteComment(userEmail: string, id: string) { 6 | await db 7 | .delete(activityTable) 8 | .where( 9 | and(eq(activityTable.id, id), eq(activityTable.userEmail, userEmail)), 10 | ); 11 | } 12 | 13 | export default deleteComment; 14 | -------------------------------------------------------------------------------- /apps/api/src/activity/controllers/get-activities.ts: -------------------------------------------------------------------------------- 1 | import { desc, eq } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { activityTable } from "../../database/schema"; 4 | 5 | async function getActivitiesFromTaskId(taskId: string) { 6 | const activities = await db.query.activityTable.findMany({ 7 | where: eq(activityTable.taskId, taskId), 8 | orderBy: [desc(activityTable.createdAt), desc(activityTable.id)], 9 | }); 10 | return activities; 11 | } 12 | 13 | export default getActivitiesFromTaskId; 14 | -------------------------------------------------------------------------------- /apps/api/src/activity/controllers/update-comment.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { activityTable } from "../../database/schema"; 4 | 5 | async function updateComment(userEmail: string, id: string, content: string) { 6 | return await db 7 | .update(activityTable) 8 | .set({ content }) 9 | .where( 10 | and(eq(activityTable.id, id), eq(activityTable.userEmail, userEmail)), 11 | ); 12 | } 13 | 14 | export default updateComment; 15 | -------------------------------------------------------------------------------- /apps/api/src/database/index.ts: -------------------------------------------------------------------------------- 1 | import { join } from "node:path"; 2 | import Database from "better-sqlite3"; 3 | import { drizzle } from "drizzle-orm/better-sqlite3"; 4 | import * as schema from "./schema"; 5 | 6 | const dbPath = process.env.DB_PATH 7 | ? process.env.DB_PATH 8 | : join(process.cwd(), "kaneo.db"); 9 | 10 | const sqlite = new Database(dbPath); 11 | 12 | const db = drizzle(sqlite, { schema }); 13 | 14 | export default db; 15 | -------------------------------------------------------------------------------- /apps/api/src/events/index.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "node:events"; 2 | 3 | const EVENTS = new EventEmitter(); 4 | EVENTS.setMaxListeners(100); 5 | 6 | export type EventPayload = { 7 | type: string; 8 | data: T; 9 | timestamp: string; 10 | }; 11 | 12 | export async function shutdownEventBus(): Promise { 13 | EVENTS.removeAllListeners(); 14 | } 15 | 16 | export async function publishEvent( 17 | eventType: string, 18 | data: unknown, 19 | ): Promise { 20 | const payload: EventPayload = { 21 | type: eventType, 22 | data, 23 | timestamp: new Date().toISOString(), 24 | }; 25 | 26 | try { 27 | EVENTS.emit(eventType, payload); 28 | } catch (error) { 29 | console.error("Failed to publish event:", error); 30 | throw error; 31 | } 32 | } 33 | 34 | export async function subscribeToEvent( 35 | eventType: string, 36 | handler: (data: T) => Promise, 37 | ): Promise { 38 | try { 39 | EVENTS.on(eventType, async (payload: EventPayload) => { 40 | try { 41 | await handler(payload.data); 42 | } catch (error) { 43 | console.error(`Error processing event ${eventType}:`, error); 44 | } 45 | }); 46 | } catch (error) { 47 | console.error("Failed to subscribe to event:", error); 48 | throw error; 49 | } 50 | } 51 | 52 | process.on("SIGTERM", () => { 53 | shutdownEventBus().catch(console.error); 54 | }); 55 | -------------------------------------------------------------------------------- /apps/api/src/label/controllers/create-label.ts: -------------------------------------------------------------------------------- 1 | import db from "../../database"; 2 | import { labelTable } from "../../database/schema"; 3 | 4 | async function createLabel(name: string, color: string, taskId: string) { 5 | const [label] = await db 6 | .insert(labelTable) 7 | .values({ name, color, taskId }) 8 | .returning(); 9 | 10 | return label; 11 | } 12 | 13 | export default createLabel; 14 | -------------------------------------------------------------------------------- /apps/api/src/label/controllers/delete-label.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { labelTable } from "../../database/schema"; 5 | 6 | async function deleteLabel(id: string) { 7 | const label = db.query.labelTable.findFirst({ 8 | where: (label, { eq }) => eq(label.id, id), 9 | }); 10 | 11 | if (!label) { 12 | throw new HTTPException(404, { 13 | message: "Label not found", 14 | }); 15 | } 16 | 17 | const [deletedLabel] = await db 18 | .delete(labelTable) 19 | .where(eq(labelTable.id, id)) 20 | .returning(); 21 | 22 | return deletedLabel; 23 | } 24 | 25 | export default deleteLabel; 26 | -------------------------------------------------------------------------------- /apps/api/src/label/controllers/get-label.ts: -------------------------------------------------------------------------------- 1 | import { HTTPException } from "hono/http-exception"; 2 | import db from "../../database"; 3 | 4 | function getLabel(id: string) { 5 | const label = db.query.labelTable.findFirst({ 6 | where: (label, { eq }) => eq(label.id, id), 7 | }); 8 | 9 | if (!label) { 10 | throw new HTTPException(404, { 11 | message: "Label not found", 12 | }); 13 | } 14 | 15 | return label; 16 | } 17 | 18 | export default getLabel; 19 | -------------------------------------------------------------------------------- /apps/api/src/label/controllers/get-labels-by-task-id.ts: -------------------------------------------------------------------------------- 1 | import db from "../../database"; 2 | 3 | async function getLabelsByTaskId(taskId: string) { 4 | const labels = await db.query.labelTable.findMany({ 5 | where: (label, { eq }) => eq(label.taskId, taskId), 6 | }); 7 | 8 | return labels; 9 | } 10 | 11 | export default getLabelsByTaskId; 12 | -------------------------------------------------------------------------------- /apps/api/src/label/controllers/update-label.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { labelTable } from "../../database/schema"; 5 | 6 | async function updateLabel(id: string, name: string, color: string) { 7 | const label = await db.query.labelTable.findFirst({ 8 | where: (label, { eq }) => eq(label.id, id), 9 | }); 10 | 11 | if (!label) { 12 | throw new HTTPException(404, { 13 | message: "Label not found", 14 | }); 15 | } 16 | 17 | const [updatedLabel] = await db 18 | .update(labelTable) 19 | .set({ name, color }) 20 | .where(eq(labelTable.id, id)) 21 | .returning(); 22 | 23 | return updatedLabel; 24 | } 25 | 26 | export default updateLabel; 27 | -------------------------------------------------------------------------------- /apps/api/src/middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | import { getCookie, setCookie } from "hono/cookie"; 2 | import { createMiddleware } from "hono/factory"; 3 | import isInSecureMode from "../user/utils/is-in-secure-mode"; 4 | import { validateSessionToken } from "../user/utils/validate-session-token"; 5 | 6 | export const auth = createMiddleware(async (c, next) => { 7 | const token = getCookie(c, "session"); 8 | 9 | if (!token) { 10 | return c.json({ user: null }); 11 | } 12 | 13 | const { user, session: validatedSession } = await validateSessionToken(token); 14 | 15 | if (!user || !validatedSession) { 16 | return c.json({ user: null }); 17 | } 18 | 19 | setCookie(c, "session", token, { 20 | path: "/", 21 | secure: isInSecureMode(c.req), 22 | sameSite: "lax", 23 | expires: validatedSession.expiresAt, 24 | }); 25 | 26 | c.set("userEmail", user.email); 27 | 28 | return next(); 29 | }); 30 | -------------------------------------------------------------------------------- /apps/api/src/notification/controllers/clear-notifications.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { notificationTable } from "../../database/schema"; 4 | 5 | async function clearNotifications(userEmail: string) { 6 | await db 7 | .delete(notificationTable) 8 | .where(eq(notificationTable.userEmail, userEmail)); 9 | 10 | return { success: true }; 11 | } 12 | 13 | export default clearNotifications; 14 | -------------------------------------------------------------------------------- /apps/api/src/notification/controllers/create-notification.ts: -------------------------------------------------------------------------------- 1 | import { createId } from "@paralleldrive/cuid2"; 2 | import db from "../../database"; 3 | import { notificationTable } from "../../database/schema"; 4 | import { publishEvent } from "../../events"; 5 | 6 | async function createNotification({ 7 | userEmail, 8 | title, 9 | content, 10 | type, 11 | resourceId, 12 | resourceType, 13 | }: { 14 | userEmail: string; 15 | title: string; 16 | content?: string; 17 | type?: string; 18 | resourceId?: string; 19 | resourceType?: string; 20 | }) { 21 | const [notification] = await db 22 | .insert(notificationTable) 23 | .values({ 24 | id: createId(), 25 | userEmail, 26 | title, 27 | content: content || "", 28 | type: type || "info", 29 | resourceId: resourceId || null, 30 | resourceType: resourceType || null, 31 | }) 32 | .returning(); 33 | 34 | if (notification) { 35 | await publishEvent("notification.created", { 36 | notificationId: notification.id, 37 | userEmail, 38 | }); 39 | } 40 | 41 | return notification; 42 | } 43 | 44 | export default createNotification; 45 | -------------------------------------------------------------------------------- /apps/api/src/notification/controllers/get-notifications.ts: -------------------------------------------------------------------------------- 1 | import { desc, eq } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { notificationTable } from "../../database/schema"; 4 | 5 | async function getNotifications(userEmail: string) { 6 | const notifications = await db 7 | .select() 8 | .from(notificationTable) 9 | .where(eq(notificationTable.userEmail, userEmail)) 10 | .orderBy(desc(notificationTable.createdAt)) 11 | .limit(50); 12 | 13 | return notifications; 14 | } 15 | 16 | export default getNotifications; 17 | -------------------------------------------------------------------------------- /apps/api/src/notification/controllers/mark-all-notifications-as-read.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { notificationTable } from "../../database/schema"; 4 | 5 | async function markAllNotificationsAsRead(userEmail: string) { 6 | await db 7 | .update(notificationTable) 8 | .set({ isRead: true }) 9 | .where(eq(notificationTable.userEmail, userEmail)); 10 | 11 | return { success: true }; 12 | } 13 | 14 | export default markAllNotificationsAsRead; 15 | -------------------------------------------------------------------------------- /apps/api/src/notification/controllers/mark-notification-as-read.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { notificationTable } from "../../database/schema"; 5 | 6 | async function markNotificationAsRead(id: string) { 7 | const [notification] = await db 8 | .update(notificationTable) 9 | .set({ isRead: true }) 10 | .where(eq(notificationTable.id, id)) 11 | .returning(); 12 | 13 | if (!notification) { 14 | throw new HTTPException(404, { 15 | message: "Notification not found", 16 | }); 17 | } 18 | 19 | return notification; 20 | } 21 | 22 | export default markNotificationAsRead; 23 | -------------------------------------------------------------------------------- /apps/api/src/project/controllers/create-project.ts: -------------------------------------------------------------------------------- 1 | import db from "../../database"; 2 | import { projectTable } from "../../database/schema"; 3 | 4 | async function createProject( 5 | workspaceId: string, 6 | name: string, 7 | icon: string, 8 | slug: string, 9 | ) { 10 | const [createdProject] = await db 11 | .insert(projectTable) 12 | .values({ 13 | workspaceId, 14 | name, 15 | icon, 16 | slug, 17 | }) 18 | .returning(); 19 | 20 | return createdProject; 21 | } 22 | 23 | export default createProject; 24 | -------------------------------------------------------------------------------- /apps/api/src/project/controllers/delete-project.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { projectTable } from "../../database/schema"; 5 | 6 | async function deleteProject(id: string) { 7 | const [existingProject] = await db 8 | .select() 9 | .from(projectTable) 10 | .where(eq(projectTable.id, id)); 11 | 12 | const isProjectExisting = Boolean(existingProject); 13 | 14 | if (!isProjectExisting) { 15 | throw new HTTPException(404, { 16 | message: "Project doesn't exist", 17 | }); 18 | } 19 | 20 | const [deletedProject] = await db 21 | .delete(projectTable) 22 | .where(eq(projectTable.id, id)) 23 | .returning(); 24 | 25 | return deletedProject; 26 | } 27 | 28 | export default deleteProject; 29 | -------------------------------------------------------------------------------- /apps/api/src/project/controllers/get-project.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { projectTable, workspaceTable } from "../../database/schema"; 5 | 6 | async function getProject(id: string, workspaceId: string) { 7 | const [project] = await db 8 | .select({ 9 | id: projectTable.id, 10 | name: projectTable.name, 11 | slug: projectTable.slug, 12 | description: projectTable.description, 13 | workspaceId: projectTable.workspaceId, 14 | workspace: workspaceTable, 15 | icon: projectTable.icon, 16 | }) 17 | .from(projectTable) 18 | .leftJoin(workspaceTable, eq(projectTable.workspaceId, workspaceTable.id)) 19 | .where( 20 | and(eq(projectTable.id, id), eq(projectTable.workspaceId, workspaceId)), 21 | ); 22 | 23 | if (!project) { 24 | throw new HTTPException(404, { 25 | message: "Project not found", 26 | }); 27 | } 28 | 29 | return project; 30 | } 31 | 32 | export default getProject; 33 | -------------------------------------------------------------------------------- /apps/api/src/project/controllers/get-projects.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { projectTable, workspaceTable } from "../../database/schema"; 4 | 5 | async function getProjects(workspaceId: string) { 6 | const projects = await db 7 | .select({ 8 | id: projectTable.id, 9 | name: projectTable.name, 10 | description: projectTable.description, 11 | workspaceId: projectTable.workspaceId, 12 | createdAt: projectTable.createdAt, 13 | icon: projectTable.icon, 14 | slug: projectTable.slug, 15 | workspace: { 16 | id: workspaceTable.id, 17 | name: workspaceTable.name, 18 | }, 19 | }) 20 | .from(projectTable) 21 | .leftJoin(workspaceTable, eq(projectTable.workspaceId, workspaceTable.id)) 22 | .where(and(eq(projectTable.workspaceId, workspaceId))); 23 | 24 | return projects.map((project) => ({ 25 | ...project, 26 | columns: [], 27 | plannedTasks: [], 28 | archivedTasks: [], 29 | })); 30 | } 31 | 32 | export default getProjects; 33 | -------------------------------------------------------------------------------- /apps/api/src/project/controllers/update-project.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { projectTable } from "../../database/schema"; 5 | 6 | async function updateProject( 7 | id: string, 8 | name: string, 9 | icon: string, 10 | slug: string, 11 | description: string, 12 | ) { 13 | const [existingProject] = await db 14 | .select() 15 | .from(projectTable) 16 | .where(eq(projectTable.id, id)); 17 | 18 | const isProjectExisting = Boolean(existingProject); 19 | 20 | if (!isProjectExisting) { 21 | throw new HTTPException(404, { 22 | message: "Project doesn't exist", 23 | }); 24 | } 25 | 26 | const [updatedWorkspace] = await db 27 | .update(projectTable) 28 | .set({ 29 | name, 30 | icon, 31 | slug, 32 | description, 33 | }) 34 | .where(eq(projectTable.id, id)) 35 | .returning(); 36 | 37 | return updatedWorkspace; 38 | } 39 | 40 | export default updateProject; 41 | -------------------------------------------------------------------------------- /apps/api/src/task/controllers/create-task.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { taskTable, userTable } from "../../database/schema"; 5 | import { publishEvent } from "../../events"; 6 | import getNextTaskNumber from "./get-next-task-number"; 7 | 8 | async function createTask({ 9 | projectId, 10 | userEmail, 11 | title, 12 | status, 13 | dueDate, 14 | description, 15 | priority, 16 | }: { 17 | projectId: string; 18 | userEmail?: string; 19 | title: string; 20 | status: string; 21 | dueDate?: Date; 22 | description?: string; 23 | priority?: string; 24 | }) { 25 | const [assignee] = await db 26 | .select({ name: userTable.name }) 27 | .from(userTable) 28 | .where(eq(userTable.email, userEmail ?? "")); 29 | 30 | const nextTaskNumber = await getNextTaskNumber(projectId); 31 | 32 | const [createdTask] = await db 33 | .insert(taskTable) 34 | .values({ 35 | projectId, 36 | userEmail: userEmail || null, 37 | title: title || "", 38 | status: status || "", 39 | dueDate: dueDate || new Date(), 40 | description: description || "", 41 | priority: priority || "", 42 | number: nextTaskNumber + 1, 43 | }) 44 | .returning(); 45 | 46 | if (!createdTask) { 47 | throw new HTTPException(500, { 48 | message: "Failed to create task", 49 | }); 50 | } 51 | 52 | await publishEvent("task.created", { 53 | taskId: createdTask.id, 54 | userEmail: createdTask.userEmail ?? "", 55 | type: "create", 56 | content: "created the task", 57 | }); 58 | 59 | return { 60 | ...createdTask, 61 | assigneeName: assignee?.name, 62 | }; 63 | } 64 | 65 | export default createTask; 66 | -------------------------------------------------------------------------------- /apps/api/src/task/controllers/delete-task.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { taskTable } from "../../database/schema"; 5 | 6 | async function deleteTask(taskId: string) { 7 | const task = await db 8 | .delete(taskTable) 9 | .where(eq(taskTable.id, taskId)) 10 | .returning() 11 | .execute(); 12 | 13 | if (!task) { 14 | throw new HTTPException(404, { 15 | message: "Task not found", 16 | }); 17 | } 18 | 19 | return task; 20 | } 21 | 22 | export default deleteTask; 23 | -------------------------------------------------------------------------------- /apps/api/src/task/controllers/get-next-task-number.ts: -------------------------------------------------------------------------------- 1 | import { count, eq } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { taskTable } from "../../database/schema"; 4 | 5 | async function getNextTaskNumber(projectId: string) { 6 | const [task] = await db 7 | .select({ count: count() }) 8 | .from(taskTable) 9 | .where(eq(taskTable.projectId, projectId)); 10 | 11 | return task ? task.count : 0; 12 | } 13 | 14 | export default getNextTaskNumber; 15 | -------------------------------------------------------------------------------- /apps/api/src/task/controllers/get-task.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { taskTable } from "../../database/schema"; 5 | 6 | async function getTask(taskId: string) { 7 | const task = await db.query.taskTable.findFirst({ 8 | where: eq(taskTable.id, taskId), 9 | }); 10 | 11 | if (!task) { 12 | throw new HTTPException(404, { 13 | message: "Task not found", 14 | }); 15 | } 16 | 17 | return task; 18 | } 19 | 20 | export default getTask; 21 | -------------------------------------------------------------------------------- /apps/api/src/task/controllers/update-task-status.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { taskTable } from "../../database/schema"; 4 | 5 | async function updateTaskStatus({ 6 | id, 7 | status, 8 | userEmail, 9 | }: { id: string; status: string; userEmail: string }) { 10 | await db 11 | .update(taskTable) 12 | .set({ status, userEmail }) 13 | .where(eq(taskTable.id, id)); 14 | } 15 | 16 | export default updateTaskStatus; 17 | -------------------------------------------------------------------------------- /apps/api/src/time-entry/controllers/create-time-entry.ts: -------------------------------------------------------------------------------- 1 | import { createId } from "@paralleldrive/cuid2"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { timeEntryTable } from "../../database/schema"; 5 | import { publishEvent } from "../../events"; 6 | 7 | async function createTimeEntry({ 8 | taskId, 9 | userEmail, 10 | description, 11 | startTime, 12 | endTime, 13 | duration, 14 | }: { 15 | taskId: string; 16 | userEmail: string; 17 | description?: string; 18 | startTime: Date; 19 | endTime?: Date; 20 | duration?: number; 21 | }) { 22 | const [createdTimeEntry] = await db 23 | .insert(timeEntryTable) 24 | .values({ 25 | id: createId(), 26 | taskId, 27 | userEmail, 28 | description: description || "", 29 | startTime, 30 | endTime: endTime || null, 31 | duration: duration || 0, 32 | }) 33 | .returning(); 34 | 35 | if (!createdTimeEntry) { 36 | throw new HTTPException(500, { 37 | message: "Failed to create time entry", 38 | }); 39 | } 40 | 41 | await publishEvent("time-entry.created", { 42 | timeEntryId: createdTimeEntry.id, 43 | taskId: createdTimeEntry.taskId, 44 | userEmail, 45 | type: "create", 46 | content: "started time tracking", 47 | }); 48 | 49 | return createdTimeEntry; 50 | } 51 | 52 | export default createTimeEntry; 53 | -------------------------------------------------------------------------------- /apps/api/src/time-entry/controllers/get-time-entries.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { timeEntryTable, userTable } from "../../database/schema"; 4 | 5 | async function getTimeEntriesByTaskId(taskId: string) { 6 | const timeEntries = await db 7 | .select({ 8 | id: timeEntryTable.id, 9 | taskId: timeEntryTable.taskId, 10 | userEmail: timeEntryTable.userEmail, 11 | userName: userTable.name, 12 | description: timeEntryTable.description, 13 | startTime: timeEntryTable.startTime, 14 | endTime: timeEntryTable.endTime, 15 | duration: timeEntryTable.duration, 16 | createdAt: timeEntryTable.createdAt, 17 | }) 18 | .from(timeEntryTable) 19 | .leftJoin(userTable, eq(timeEntryTable.userEmail, userTable.email)) 20 | .where(eq(timeEntryTable.taskId, taskId)) 21 | .orderBy(timeEntryTable.startTime); 22 | 23 | return timeEntries; 24 | } 25 | 26 | export default getTimeEntriesByTaskId; 27 | -------------------------------------------------------------------------------- /apps/api/src/time-entry/controllers/get-time-entry.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { timeEntryTable } from "../../database/schema"; 4 | 5 | async function getTimeEntry(id: string) { 6 | const [timeEntry] = await db 7 | .select() 8 | .from(timeEntryTable) 9 | .where(eq(timeEntryTable.id, id)); 10 | 11 | return timeEntry; 12 | } 13 | 14 | export default getTimeEntry; 15 | -------------------------------------------------------------------------------- /apps/api/src/time-entry/controllers/update-time-entry.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { timeEntryTable } from "../../database/schema"; 5 | 6 | async function updateTimeEntry( 7 | timeEntryId: string, 8 | endTime: Date, 9 | duration: number, 10 | ) { 11 | const [existingTimeEntry] = await db 12 | .select() 13 | .from(timeEntryTable) 14 | .where(eq(timeEntryTable.id, timeEntryId)); 15 | 16 | if (!existingTimeEntry) { 17 | throw new HTTPException(404, { 18 | message: "Time entry not found", 19 | }); 20 | } 21 | 22 | const [updatedTimeEntry] = await db 23 | .update(timeEntryTable) 24 | .set({ 25 | endTime, 26 | duration, 27 | }) 28 | .where(eq(timeEntryTable.id, timeEntryId)) 29 | .returning(); 30 | 31 | return updatedTimeEntry; 32 | } 33 | 34 | export default updateTimeEntry; 35 | -------------------------------------------------------------------------------- /apps/api/src/user/controllers/sign-in.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | import db from "../../database"; 3 | 4 | async function signIn(email: string, password: string) { 5 | const user = await db.query.userTable.findFirst({ 6 | where: (users, { eq }) => eq(users.email, email), 7 | }); 8 | 9 | if (!user) { 10 | throw new Error("User not found"); 11 | } 12 | 13 | const isPasswordValid = await bcrypt.compare(password, user.password); 14 | 15 | if (!isPasswordValid) { 16 | throw new Error("Invalid credentials"); 17 | } 18 | 19 | return { 20 | id: user.id, 21 | email: user.email, 22 | name: user.name, 23 | }; 24 | } 25 | 26 | export default signIn; 27 | -------------------------------------------------------------------------------- /apps/api/src/user/controllers/sign-up.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { userTable } from "../../database/schema"; 5 | import { publishEvent } from "../../events"; 6 | import getSettings from "../../utils/get-settings"; 7 | 8 | async function signUp(email: string, password: string, name: string) { 9 | const { disableRegistration, isDemoMode } = getSettings(); 10 | 11 | if (disableRegistration && !isDemoMode) { 12 | throw new HTTPException(400, { 13 | message: "Registration is disabled on this instance", 14 | }); 15 | } 16 | 17 | const isEmailTaken = Boolean( 18 | await db.query.userTable.findFirst({ 19 | where: (users, { eq }) => eq(users.email, email), 20 | }), 21 | ); 22 | 23 | if (isEmailTaken) { 24 | throw new HTTPException(400, { 25 | message: "Email taken", 26 | }); 27 | } 28 | 29 | const hashedPassword = await bcrypt.hash(password, 10); 30 | 31 | const user = ( 32 | await db 33 | .insert(userTable) 34 | .values({ email, name, password: hashedPassword }) 35 | .returning() 36 | ).at(0); 37 | 38 | if (!user) { 39 | throw new HTTPException(500, { 40 | message: "Failed to create an account", 41 | }); 42 | } 43 | 44 | publishEvent("user.signed_up", { 45 | email: user.email, 46 | }); 47 | 48 | return { 49 | id: user.id, 50 | email: user.email, 51 | name: user.name, 52 | }; 53 | } 54 | 55 | export default signUp; 56 | -------------------------------------------------------------------------------- /apps/api/src/user/utils/create-session.ts: -------------------------------------------------------------------------------- 1 | import { sha256 } from "@oslojs/crypto/sha2"; 2 | import { encodeHexLowerCase } from "@oslojs/encoding"; 3 | import db from "../../database"; 4 | import { sessionTable } from "../../database/schema"; 5 | 6 | async function createSession(token: string, userId: string) { 7 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 8 | const session = { 9 | id: sessionId, 10 | userId, 11 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), 12 | }; 13 | await db.insert(sessionTable).values(session); 14 | return session; 15 | } 16 | 17 | export default createSession; 18 | -------------------------------------------------------------------------------- /apps/api/src/user/utils/generate-session-token.ts: -------------------------------------------------------------------------------- 1 | import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding"; 2 | 3 | function generateSessionToken(): string { 4 | const bytes = new Uint8Array(20); 5 | crypto.getRandomValues(bytes); 6 | const token = encodeBase32LowerCaseNoPadding(bytes); 7 | return token; 8 | } 9 | 10 | export default generateSessionToken; 11 | -------------------------------------------------------------------------------- /apps/api/src/user/utils/invalidate-session.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { sessionTable } from "../../database/schema"; 4 | 5 | async function invalidateSession(sessionId: string) { 6 | await db.delete(sessionTable).where(eq(sessionTable.id, sessionId)); 7 | } 8 | 9 | export default invalidateSession; 10 | -------------------------------------------------------------------------------- /apps/api/src/user/utils/is-in-secure-mode.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from "hono"; 2 | 3 | export default function isInSecureMode(request: Context["req"]) { 4 | return request.header("x-forwarded-proto") === "https"; 5 | } 6 | -------------------------------------------------------------------------------- /apps/api/src/user/utils/validate-session-token.ts: -------------------------------------------------------------------------------- 1 | import { sha256 } from "@oslojs/crypto/sha2"; 2 | import { encodeHexLowerCase } from "@oslojs/encoding"; 3 | import { eq } from "drizzle-orm"; 4 | import db from "../../database"; 5 | import { sessionTable, userTable } from "../../database/schema"; 6 | 7 | export async function validateSessionToken(token: string) { 8 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 9 | const sessions = await db 10 | .select({ user: userTable, session: sessionTable }) 11 | .from(sessionTable) 12 | .innerJoin(userTable, eq(sessionTable.userId, userTable.id)) 13 | .where(eq(sessionTable.id, sessionId)); 14 | 15 | if (sessions.length < 1 || !sessions[0]) { 16 | return { session: null, user: null }; 17 | } 18 | 19 | const { user, session } = sessions[0]; 20 | 21 | const isSessionExpired = Date.now() >= session.expiresAt.getTime(); 22 | 23 | if (isSessionExpired) { 24 | await db.delete(sessionTable).where(eq(sessionTable.id, session.id)); 25 | return { session: null, user: null }; 26 | } 27 | 28 | const isSessionHalfWayExpired = 29 | Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15; 30 | 31 | if (isSessionHalfWayExpired) { 32 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 33 | await db 34 | .update(sessionTable) 35 | .set({ 36 | expiresAt: session.expiresAt, 37 | }) 38 | .where(eq(sessionTable.id, session.id)); 39 | } 40 | 41 | return { session, user }; 42 | } 43 | -------------------------------------------------------------------------------- /apps/api/src/utils/create-demo-user.ts: -------------------------------------------------------------------------------- 1 | import { createId } from "@paralleldrive/cuid2"; 2 | import bcrypt from "bcrypt"; 3 | import db from "../database"; 4 | import { userTable } from "../database/schema"; 5 | import createSession from "../user/utils/create-session"; 6 | import generateSessionToken from "../user/utils/generate-session-token"; 7 | import { generateDemoName } from "./generate-demo-name"; 8 | export async function createDemoUser() { 9 | const demoId = createId(); 10 | const demoName = generateDemoName(); 11 | const demoEmail = `${demoName}-${demoId}@kaneo.app`; 12 | 13 | const hashedPassword = await bcrypt.hash("demo", 10); 14 | 15 | await db.insert(userTable).values({ 16 | id: demoId, 17 | name: demoName 18 | .split("-") 19 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 20 | .join(" "), 21 | email: demoEmail, 22 | password: hashedPassword, 23 | }); 24 | 25 | const token = generateSessionToken(); 26 | const demoSession = await createSession(token, demoId); 27 | 28 | return { 29 | id: demoId, 30 | name: demoName, 31 | email: demoEmail, 32 | session: token, 33 | expiresAt: demoSession.expiresAt, 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /apps/api/src/utils/generate-demo-name.ts: -------------------------------------------------------------------------------- 1 | const adjectives = [ 2 | "fractious", 3 | "whimsical", 4 | "zealous", 5 | "dazzling", 6 | "ethereal", 7 | "tenacious", 8 | "luminous", 9 | "mystical", 10 | "radiant", 11 | "vivacious", 12 | "jubilant", 13 | "serene", 14 | "cosmic", 15 | "dynamic", 16 | "enigmatic", 17 | "mystical", 18 | "radiant", 19 | "vivacious", 20 | "jubilant", 21 | "serene", 22 | "cosmic", 23 | "dynamic", 24 | ]; 25 | 26 | const animals = [ 27 | "monkfish", 28 | "phoenix", 29 | "griffin", 30 | "dragon", 31 | "unicorn", 32 | "kraken", 33 | "sphinx", 34 | "chimera", 35 | "pegasus", 36 | "hydra", 37 | "lynx", 38 | "falcon", 39 | "octopus", 40 | "panther", 41 | "dolphin", 42 | "tiger", 43 | "elephant", 44 | "giraffe", 45 | "hippopotamus", 46 | "kangaroo", 47 | "leopard", 48 | "lion", 49 | ]; 50 | 51 | export function generateDemoName(): string { 52 | const adjective = adjectives[Math.floor(Math.random() * adjectives.length)]; 53 | const animal = animals[Math.floor(Math.random() * animals.length)]; 54 | return `${adjective}-${animal}`; 55 | } 56 | -------------------------------------------------------------------------------- /apps/api/src/utils/get-settings.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | 3 | dotenv.config(); 4 | 5 | function getSettings() { 6 | return { 7 | disableRegistration: process.env.DISABLE_REGISTRATION === "true", 8 | isDemoMode: process.env.DEMO_MODE === "true", 9 | }; 10 | } 11 | 12 | export default getSettings; 13 | -------------------------------------------------------------------------------- /apps/api/src/utils/purge-demo-data.ts: -------------------------------------------------------------------------------- 1 | import { projectTable } from "../database/schema"; 2 | import { taskTable } from "../database/schema"; 3 | import { userTable } from "../database/schema"; 4 | import { workspaceTable } from "../database/schema"; 5 | import { workspaceUserTable } from "../database/schema"; 6 | 7 | import db from "../database"; 8 | 9 | async function purgeData() { 10 | await db.delete(userTable); 11 | await db.delete(workspaceTable); 12 | await db.delete(workspaceUserTable); 13 | await db.delete(projectTable); 14 | await db.delete(taskTable); 15 | } 16 | 17 | export default purgeData; 18 | -------------------------------------------------------------------------------- /apps/api/src/utils/set-demo-user.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore - This is used by Elysia 2 | import type { ElysiaCookie } from "elysia/dist/cookies"; 3 | import type { Context } from "hono"; 4 | import { setCookie } from "hono/cookie"; 5 | import isInSecureMode from "../user/utils/is-in-secure-mode"; 6 | import { createDemoUser } from "./create-demo-user"; 7 | 8 | async function setDemoUser(c: Context) { 9 | const demoExpiresAt = new Date(Date.now() + 15 * 60 * 1000); 10 | const { session: demoSession, expiresAt = demoExpiresAt } = 11 | await createDemoUser(); 12 | 13 | setCookie(c, "session", demoSession, { 14 | httpOnly: isInSecureMode(c.req), 15 | path: "/", 16 | secure: true, 17 | sameSite: "lax", 18 | expires: expiresAt, 19 | }); 20 | } 21 | 22 | export default setDemoUser; 23 | -------------------------------------------------------------------------------- /apps/api/src/workspace-user/controllers/create-root-workspace-user.ts: -------------------------------------------------------------------------------- 1 | import db from "../../database"; 2 | import { workspaceUserTable } from "../../database/schema"; 3 | 4 | async function createRootWorkspaceUser(workspaceId: string, userEmail: string) { 5 | const [workspaceUser] = await db 6 | .insert(workspaceUserTable) 7 | .values({ 8 | workspaceId, 9 | userEmail, 10 | role: "owner", 11 | status: "active", 12 | }) 13 | .returning(); 14 | 15 | return workspaceUser; 16 | } 17 | 18 | export default createRootWorkspaceUser; 19 | -------------------------------------------------------------------------------- /apps/api/src/workspace-user/controllers/delete-workspace-user.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { workspaceUserTable } from "../../database/schema"; 4 | 5 | async function deleteWorkspaceUser(workspaceId: string, userEmail: string) { 6 | const [deletedWorkspaceUser] = await db 7 | .delete(workspaceUserTable) 8 | .where( 9 | and( 10 | eq(workspaceUserTable.workspaceId, workspaceId), 11 | eq(workspaceUserTable.userEmail, userEmail), 12 | ), 13 | ) 14 | .returning(); 15 | 16 | return deletedWorkspaceUser; 17 | } 18 | 19 | export default deleteWorkspaceUser; 20 | -------------------------------------------------------------------------------- /apps/api/src/workspace-user/controllers/get-active-workspace-users.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { userTable, workspaceUserTable } from "../../database/schema"; 4 | 5 | async function getActiveWorkspaceUsers(workspaceId: string) { 6 | const activeWorkspaceUsers = await db 7 | .select({ 8 | id: workspaceUserTable.id, 9 | userEmail: workspaceUserTable.userEmail, 10 | userName: userTable.name, 11 | role: workspaceUserTable.role, 12 | status: workspaceUserTable.status, 13 | }) 14 | .from(workspaceUserTable) 15 | .where( 16 | and( 17 | eq(workspaceUserTable.workspaceId, workspaceId), 18 | eq(workspaceUserTable.status, "active"), 19 | ), 20 | ) 21 | .innerJoin(userTable, eq(workspaceUserTable.userEmail, userTable.email)); 22 | 23 | return activeWorkspaceUsers; 24 | } 25 | 26 | export default getActiveWorkspaceUsers; 27 | -------------------------------------------------------------------------------- /apps/api/src/workspace-user/controllers/get-workspace-user.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { workspaceUserTable } from "../../database/schema"; 5 | 6 | async function getWorkspaceUser(id: string) { 7 | const workspaceUser = await db.query.workspaceUserTable.findFirst({ 8 | where: eq(workspaceUserTable.id, id), 9 | }); 10 | 11 | if (!workspaceUser) { 12 | throw new HTTPException(404, { 13 | message: "Workspace user not found", 14 | }); 15 | } 16 | 17 | return workspaceUser; 18 | } 19 | 20 | export default getWorkspaceUser; 21 | -------------------------------------------------------------------------------- /apps/api/src/workspace-user/controllers/get-workspace-users.ts: -------------------------------------------------------------------------------- 1 | import { asc, eq } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { userTable, workspaceUserTable } from "../../database/schema"; 4 | 5 | function getWorkspaceUsers(workspaceId: string) { 6 | return db 7 | .select({ 8 | userEmail: workspaceUserTable.userEmail, 9 | userName: userTable.name, 10 | joinedAt: workspaceUserTable.joinedAt, 11 | status: workspaceUserTable.status, 12 | role: workspaceUserTable.role, 13 | }) 14 | .from(workspaceUserTable) 15 | .leftJoin(userTable, eq(workspaceUserTable.userEmail, userTable.email)) 16 | .where(eq(workspaceUserTable.workspaceId, workspaceId)) 17 | .orderBy(asc(workspaceUserTable.status)); 18 | } 19 | 20 | export default getWorkspaceUsers; 21 | -------------------------------------------------------------------------------- /apps/api/src/workspace-user/controllers/invite-workspace-user.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { workspaceTable, workspaceUserTable } from "../../database/schema"; 5 | 6 | async function inviteWorkspaceUser(workspaceId: string, userEmail: string) { 7 | const [workspace] = await db 8 | .select() 9 | .from(workspaceTable) 10 | .where(eq(workspaceTable.id, workspaceId)); 11 | 12 | if (!workspace) { 13 | throw new HTTPException(404, { 14 | message: "Workspace not found", 15 | }); 16 | } 17 | 18 | const [existingUser] = await db 19 | .select() 20 | .from(workspaceUserTable) 21 | .where( 22 | and( 23 | eq(workspaceUserTable.workspaceId, workspaceId), 24 | eq(workspaceUserTable.userEmail, userEmail), 25 | ), 26 | ); 27 | 28 | if (existingUser) { 29 | throw new HTTPException(400, { 30 | message: "User is already invited to this workspace", 31 | }); 32 | } 33 | 34 | const [invitedUser] = await db 35 | .insert(workspaceUserTable) 36 | .values({ 37 | userEmail, 38 | workspaceId, 39 | }) 40 | .returning(); 41 | 42 | return invitedUser; 43 | } 44 | 45 | export default inviteWorkspaceUser; 46 | -------------------------------------------------------------------------------- /apps/api/src/workspace-user/controllers/update-workspace-user.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { workspaceUserTable } from "../../database/schema"; 4 | 5 | async function updateWorkspaceUser(userEmail: string, status: string) { 6 | const [updatedWorkspaceUser] = await db 7 | .update(workspaceUserTable) 8 | .set({ status }) 9 | .where(eq(workspaceUserTable.userEmail, userEmail)) 10 | .returning(); 11 | 12 | return updatedWorkspaceUser; 13 | } 14 | 15 | export default updateWorkspaceUser; 16 | -------------------------------------------------------------------------------- /apps/api/src/workspace/controllers/create-workspace.ts: -------------------------------------------------------------------------------- 1 | import { HTTPException } from "hono/http-exception"; 2 | import db from "../../database"; 3 | import { workspaceTable } from "../../database/schema"; 4 | import { publishEvent } from "../../events"; 5 | async function createWorkspace(name: string, ownerEmail: string) { 6 | const [workspace] = await db 7 | .insert(workspaceTable) 8 | .values({ 9 | name, 10 | ownerEmail, 11 | }) 12 | .returning(); 13 | 14 | if (!workspace) { 15 | throw new HTTPException(500, { 16 | message: "Failed to create workspace", 17 | }); 18 | } 19 | 20 | publishEvent("workspace.created", { 21 | workspaceId: workspace.id, 22 | ownerEmail, 23 | }); 24 | 25 | return workspace; 26 | } 27 | 28 | export default createWorkspace; 29 | -------------------------------------------------------------------------------- /apps/api/src/workspace/controllers/delete-workspace.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { workspaceTable } from "../../database/schema"; 5 | 6 | async function deleteWorkspace(userEmail: string, workspaceId: string) { 7 | const [existingWorkspace] = await db 8 | .select({ 9 | id: workspaceTable.id, 10 | ownerEmail: workspaceTable.ownerEmail, 11 | }) 12 | .from(workspaceTable) 13 | .where( 14 | and( 15 | eq(workspaceTable.id, workspaceId), 16 | eq(workspaceTable.ownerEmail, userEmail), 17 | ), 18 | ) 19 | .limit(1); 20 | 21 | const isWorkspaceExisting = Boolean(existingWorkspace); 22 | 23 | if (!isWorkspaceExisting) { 24 | throw new HTTPException(404, { 25 | message: "Workspace not found", 26 | }); 27 | } 28 | 29 | const [deletedWorkspace] = await db 30 | .delete(workspaceTable) 31 | .where(eq(workspaceTable.id, workspaceId)) 32 | .returning({ 33 | id: workspaceTable.id, 34 | name: workspaceTable.name, 35 | ownerEmail: workspaceTable.ownerEmail, 36 | createdAt: workspaceTable.createdAt, 37 | }); 38 | 39 | return deletedWorkspace; 40 | } 41 | 42 | export default deleteWorkspace; 43 | -------------------------------------------------------------------------------- /apps/api/src/workspace/controllers/get-workspace.ts: -------------------------------------------------------------------------------- 1 | import { and, eq, or } from "drizzle-orm"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { workspaceTable, workspaceUserTable } from "../../database/schema"; 5 | 6 | async function getWorkspace(userEmail: string, workspaceId: string) { 7 | const [existingWorkspace] = await db 8 | .select({ 9 | id: workspaceTable.id, 10 | name: workspaceTable.name, 11 | ownerEmail: workspaceTable.ownerEmail, 12 | description: workspaceTable.description, 13 | createdAt: workspaceTable.createdAt, 14 | }) 15 | .from(workspaceTable) 16 | .leftJoin( 17 | workspaceUserTable, 18 | eq(workspaceTable.id, workspaceUserTable.workspaceId), 19 | ) 20 | .where( 21 | and( 22 | eq(workspaceTable.id, workspaceId), 23 | or( 24 | eq(workspaceTable.ownerEmail, userEmail), 25 | eq(workspaceUserTable.userEmail, userEmail), 26 | ), 27 | ), 28 | ) 29 | .limit(1); 30 | 31 | const isWorkspaceExisting = Boolean(existingWorkspace); 32 | 33 | if (!isWorkspaceExisting) { 34 | throw new HTTPException(404, { 35 | message: "Workspace not found", 36 | }); 37 | } 38 | 39 | return existingWorkspace; 40 | } 41 | 42 | export default getWorkspace; 43 | -------------------------------------------------------------------------------- /apps/api/src/workspace/controllers/get-workspaces.ts: -------------------------------------------------------------------------------- 1 | import { eq, or } from "drizzle-orm"; 2 | import db from "../../database"; 3 | import { workspaceTable, workspaceUserTable } from "../../database/schema"; 4 | 5 | async function getWorkspaces(userEmail: string) { 6 | const workspaces = await db 7 | .select({ 8 | id: workspaceTable.id, 9 | name: workspaceTable.name, 10 | ownerEmail: workspaceTable.ownerEmail, 11 | createdAt: workspaceTable.createdAt, 12 | description: workspaceTable.description, 13 | }) 14 | .from(workspaceTable) 15 | .leftJoin( 16 | workspaceUserTable, 17 | eq(workspaceTable.id, workspaceUserTable.workspaceId), 18 | ) 19 | .where( 20 | or( 21 | eq(workspaceTable.ownerEmail, userEmail), 22 | eq(workspaceUserTable.userEmail, userEmail), 23 | ), 24 | ) 25 | .groupBy( 26 | workspaceTable.id, 27 | workspaceTable.name, 28 | workspaceTable.ownerEmail, 29 | workspaceTable.description, 30 | ); 31 | 32 | return workspaces; 33 | } 34 | 35 | export default getWorkspaces; 36 | -------------------------------------------------------------------------------- /apps/api/src/workspace/controllers/update-workspace.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import { HTTPException } from "hono/http-exception"; 3 | import db from "../../database"; 4 | import { workspaceTable } from "../../database/schema"; 5 | 6 | async function updateWorkspace( 7 | userEmail: string, 8 | workspaceId: string, 9 | name: string, 10 | description: string, 11 | ) { 12 | const [existingWorkspace] = await db 13 | .select({ 14 | id: workspaceTable.id, 15 | ownerEmail: workspaceTable.ownerEmail, 16 | }) 17 | .from(workspaceTable) 18 | .where( 19 | and( 20 | eq(workspaceTable.id, workspaceId), 21 | eq(workspaceTable.ownerEmail, userEmail), 22 | ), 23 | ) 24 | .limit(1); 25 | 26 | const isWorkspaceExisting = Boolean(existingWorkspace); 27 | 28 | if (!isWorkspaceExisting) { 29 | throw new HTTPException(404, { 30 | message: "Workspace not found", 31 | }); 32 | } 33 | 34 | const [updatedWorkspace] = await db 35 | .update(workspaceTable) 36 | .set({ 37 | name, 38 | description, 39 | }) 40 | .where(eq(workspaceTable.id, workspaceId)) 41 | .returning({ 42 | id: workspaceTable.id, 43 | name: workspaceTable.name, 44 | ownerEmail: workspaceTable.ownerEmail, 45 | description: workspaceTable.description, 46 | createdAt: workspaceTable.createdAt, 47 | }); 48 | 49 | return updatedWorkspace; 50 | } 51 | 52 | export default updateWorkspace; 53 | -------------------------------------------------------------------------------- /apps/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../packages/typescript-config/base.json", 3 | 4 | "compilerOptions": { 5 | "outDir": "dist", 6 | "rootDir": "src", 7 | "module": "CommonJS", 8 | "moduleResolution": "Node", 9 | "target": "ES2022", 10 | "lib": [ 11 | "ES2022", 12 | "DOM" 13 | ], 14 | "sourceMap": true, 15 | "declaration": true, 16 | "skipLibCheck": true, 17 | "isolatedModules": true 18 | }, 19 | "include": [ 20 | "src/**/*" 21 | ], 22 | "exclude": [ 23 | "node_modules", 24 | "dist" 25 | ] 26 | } -------------------------------------------------------------------------------- /apps/docs/app/(home)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { baseOptions } from "@/app/layout.config"; 2 | import { HomeLayout } from "fumadocs-ui/layouts/home"; 3 | import type { Metadata } from "next"; 4 | import Script from "next/script"; 5 | import type { ReactNode } from "react"; 6 | 7 | export const metadata: Metadata = { 8 | title: "Kaneo", 9 | description: 10 | "An open source project management platform focused on simplicity and efficiency.", 11 | alternates: { 12 | canonical: "https://kaneo.app", 13 | }, 14 | openGraph: { 15 | images: ["/og.png"], 16 | }, 17 | metadataBase: new URL("https://kaneo.app"), 18 | }; 19 | 20 | export default function Layout({ children }: { children: ReactNode }) { 21 | return ( 22 | 23 |