From f1664b1899d84eb4cf1138f368abfcd491e9783c Mon Sep 17 00:00:00 2001 From: meglerhagen Date: Tue, 6 Aug 2024 09:05:34 +0200 Subject: [PATCH 1/9] inital commit for SDK PO --- apps/www/src/lib/poweroffice-sdk.ts | 64 +++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 apps/www/src/lib/poweroffice-sdk.ts diff --git a/apps/www/src/lib/poweroffice-sdk.ts b/apps/www/src/lib/poweroffice-sdk.ts new file mode 100644 index 0000000..307fc82 --- /dev/null +++ b/apps/www/src/lib/poweroffice-sdk.ts @@ -0,0 +1,64 @@ +import type { AxiosInstance } from "axios" +import axios from "axios" + +import { getCurrentUser } from "./session" + +class PowerOfficeSDK { + private api: AxiosInstance + + constructor(baseURL: string, apiKey: string) { + this.api = axios.create({ + baseURL, + }) + + this.api.interceptors.request.use(async (config) => { + const user = await getCurrentUser() + config.headers["x-fe-key"] = apiKey + if (user?.id) { + config.headers["x-user-id"] = user.id + } + return config + }) + } + + async getCustomers() { + const response = await this.api.get("/api/internal/poweroffice/customers") + return response.data + } + + async getCustomer(id: string) { + const response = await this.api.get( + `/api/internal/poweroffice/customers/${id}`, + ) + return response.data + } + + async getProducts() { + const response = await this.api.get("/api/internal/poweroffice/products") + return response.data + } + + async getProduct(id: string) { + const response = await this.api.get( + `/api/internal/poweroffice/products/${id}`, + ) + return response.data + } + + async createInvoice(invoiceData: any) { + const response = await this.api.post( + "/api/internal/poweroffice/invoices/create", + invoiceData, + ) + return response.data + } +} + +const apiUrl = + process.env.NODE_ENV === "production" + ? "https://api.propdock.workers.dev" + : "http://localhost:8787" + +const apiKey = process.env.NEXT_PUBLIC_API_KEY || "super-secret" + +export const poweroffice = new PowerOfficeSDK(apiUrl, apiKey) From 34070f2116b38aefc22d01aafc30816aaa263343 Mon Sep 17 00:00:00 2001 From: meglerhagen Date: Tue, 6 Aug 2024 11:28:53 +0200 Subject: [PATCH 2/9] init --- apps/www/.content-collections/generated/index.js | 2 +- apps/www/src/lib/poweroffice-sdk.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/www/.content-collections/generated/index.js b/apps/www/.content-collections/generated/index.js index 6ca6179..7daa05c 100644 --- a/apps/www/.content-collections/generated/index.js +++ b/apps/www/.content-collections/generated/index.js @@ -1,4 +1,4 @@ -// generated by content-collections at Tue Aug 06 2024 08:52:53 GMT+0200 (Central European Summer Time) +// generated by content-collections at Tue Aug 06 2024 09:07:31 GMT+0200 (Central European Summer Time) import allBlogPosts from "./allBlogPosts.js"; import allChangelogPosts from "./allChangelogPosts.js"; diff --git a/apps/www/src/lib/poweroffice-sdk.ts b/apps/www/src/lib/poweroffice-sdk.ts index 307fc82..597b065 100644 --- a/apps/www/src/lib/poweroffice-sdk.ts +++ b/apps/www/src/lib/poweroffice-sdk.ts @@ -57,7 +57,12 @@ class PowerOfficeSDK { const apiUrl = process.env.NODE_ENV === "production" ? "https://api.propdock.workers.dev" - : "http://localhost:8787" + : "https://api.propdock.workers.dev" + +// const apiUrl = +// process.env.NODE_ENV === "production" +// ? "https://api.propdock.workers.dev" +// : "http://localhost:8787" const apiKey = process.env.NEXT_PUBLIC_API_KEY || "super-secret" From fb0efcc78771a1d29db0676984b44a5d292b347e Mon Sep 17 00:00:00 2001 From: meglerhagen Date: Tue, 6 Aug 2024 16:58:12 +0200 Subject: [PATCH 3/9] working SDK --- .../.content-collections/generated/index.js | 2 +- .../src/app/(settings)/settings/api/page.tsx | 25 +++++++++++ apps/www/src/lib/poweroffice-sdk.ts | 43 ++++++++++++++++++- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/apps/www/.content-collections/generated/index.js b/apps/www/.content-collections/generated/index.js index 7daa05c..d0fff47 100644 --- a/apps/www/.content-collections/generated/index.js +++ b/apps/www/.content-collections/generated/index.js @@ -1,4 +1,4 @@ -// generated by content-collections at Tue Aug 06 2024 09:07:31 GMT+0200 (Central European Summer Time) +// generated by content-collections at Tue Aug 06 2024 16:55:49 GMT+0200 (Central European Summer Time) import allBlogPosts from "./allBlogPosts.js"; import allChangelogPosts from "./allChangelogPosts.js"; diff --git a/apps/www/src/app/(settings)/settings/api/page.tsx b/apps/www/src/app/(settings)/settings/api/page.tsx index 405678d..9c65674 100644 --- a/apps/www/src/app/(settings)/settings/api/page.tsx +++ b/apps/www/src/app/(settings)/settings/api/page.tsx @@ -15,6 +15,7 @@ import { import { authOptions } from "@/lib/auth" import { prisma } from "@/lib/db" +import { poweroffice } from "@/lib/poweroffice-sdk" import { AddApiKeyButton } from "@/components/buttons/AddApiKeyButton" import { DashboardHeader } from "@/components/dashboard/header" import { DashboardShell } from "@/components/dashboard/shell" @@ -44,6 +45,30 @@ export default async function SettingsPage() { if (!userApiKeys) { redirect(authOptions.pages?.signIn || "/login") } + // const x = await poweroffice.getCustomers() + // console.log(x) + + // const y = await poweroffice.getCustomer("17763838") + // console.log(y) + + // const z = await poweroffice.getProducts() + // console.log(z) + + // const a = await poweroffice.getProduct("20681528") + // console.log(a) + + // const invoiceData = { + // CurrencyCode: "NOK", + // CustomerId: 17763838, + // SalesOrderLines: [ + // { + // Description: "SDK", + // ProductId: 20681521, + // }, + // ], + // } + // const invoice = await poweroffice.createInvoice(invoiceData) + // console.log(invoice) return ( diff --git a/apps/www/src/lib/poweroffice-sdk.ts b/apps/www/src/lib/poweroffice-sdk.ts index 597b065..9298c38 100644 --- a/apps/www/src/lib/poweroffice-sdk.ts +++ b/apps/www/src/lib/poweroffice-sdk.ts @@ -23,6 +23,7 @@ class PowerOfficeSDK { async getCustomers() { const response = await this.api.get("/api/internal/poweroffice/customers") + console.log("Received response:", response.data) return response.data } @@ -56,8 +57,8 @@ class PowerOfficeSDK { const apiUrl = process.env.NODE_ENV === "production" - ? "https://api.propdock.workers.dev" - : "https://api.propdock.workers.dev" + ? "https://api.vegard.workers.dev" + : "https://api.vegard.workers.dev" // const apiUrl = // process.env.NODE_ENV === "production" @@ -67,3 +68,41 @@ const apiUrl = const apiKey = process.env.NEXT_PUBLIC_API_KEY || "super-secret" export const poweroffice = new PowerOfficeSDK(apiUrl, apiKey) + +// Developer Note: How to fetch data using PowerOffice SDK +/* + To fetch data using the PowerOffice SDK, you can use the following methods: + + // Fetch all customers + const customers = await poweroffice.getCustomers() + console.log(customers) + + // Fetch a specific customer by ID + const customer = await poweroffice.getCustomer("17763838") + console.log(customer) + + // Fetch all products + const products = await poweroffice.getProducts() + console.log(products) + + // Fetch a specific product by ID + const product = await poweroffice.getProduct("20681528") + console.log(product) + + // Create an invoice + const invoiceData = { + CurrencyCode: "NOK", + CustomerId: 17763838, + SalesOrderLines: [ + { + Description: "SDK", + ProductId: 20681521, + }, + ], + } + const invoice = await poweroffice.createInvoice(invoiceData) + console.log(invoice) + + Note: Make sure to import the PowerOffice SDK and initialize it properly before using these methods. + Also, remember to handle potential errors and implement proper error handling in production code. +*/ From 29cfd7b0010f3f89e49d83e4f8521f87d6c02129 Mon Sep 17 00:00:00 2001 From: meglerhagen Date: Tue, 6 Aug 2024 17:52:32 +0200 Subject: [PATCH 4/9] working customer and product --- .../.content-collections/generated/index.js | 2 +- .../app/(tenant)/tenant/[id]/invoice/page.tsx | 24 +- .../components/tenant/TenantSendInvoice.tsx | 209 ++++++++++++------ apps/www/src/lib/poweroffice-sdk.ts | 16 +- 4 files changed, 165 insertions(+), 86 deletions(-) diff --git a/apps/www/.content-collections/generated/index.js b/apps/www/.content-collections/generated/index.js index d0fff47..12d4019 100644 --- a/apps/www/.content-collections/generated/index.js +++ b/apps/www/.content-collections/generated/index.js @@ -1,4 +1,4 @@ -// generated by content-collections at Tue Aug 06 2024 16:55:49 GMT+0200 (Central European Summer Time) +// generated by content-collections at Tue Aug 06 2024 17:52:21 GMT+0200 (Central European Summer Time) import allBlogPosts from "./allBlogPosts.js"; import allChangelogPosts from "./allChangelogPosts.js"; diff --git a/apps/www/src/app/(tenant)/tenant/[id]/invoice/page.tsx b/apps/www/src/app/(tenant)/tenant/[id]/invoice/page.tsx index 780fc6d..218383b 100644 --- a/apps/www/src/app/(tenant)/tenant/[id]/invoice/page.tsx +++ b/apps/www/src/app/(tenant)/tenant/[id]/invoice/page.tsx @@ -9,6 +9,7 @@ import { CardTitle, } from "@dingify/ui/components/card" +import { poweroffice } from "@/lib/poweroffice-sdk" import { AddContactPersonSheet } from "@/components/buttons/AddContactPersonSheet" import { EditContactPersonSheet } from "@/components/buttons/EditContactPersonSheet" import { DashboardHeader } from "@/components/dashboard/header" @@ -16,19 +17,7 @@ import { DashboardShell } from "@/components/dashboard/shell" import { EmptyPlaceholder } from "@/components/shared/empty-placeholder" import TenantSendInvoice from "@/components/tenant/TenantSendInvoice" -const mockCustomers = [ - { id: "genesis", name: "Proaktiv Eiendomsmegling", orgnr: "123456789" }, - { id: "explorer", name: "Neural Explorer", orgnr: "987654321" }, - { id: "quantum", name: "Neural Quantum", orgnr: "192837465" }, -] - -const mockProducts = [ - { id: "product1", name: "Produkt 1", price: 100 }, - { id: "product2", name: "Produkt 2", price: 200 }, - { id: "product3", name: "Produkt 3", price: 300 }, -] - -export default async function ContactPerson({ +export default async function InvoicePage({ params, }: { params: { id: string } @@ -48,6 +37,7 @@ export default async function ContactPerson({ try { const tenantDetails = await getTenantDetails(tenantId) + const { customers, products } = await poweroffice.getCustomersAndProducts() if (!tenantDetails || tenantDetails.contacts.length === 0) { return ( @@ -56,10 +46,7 @@ export default async function ContactPerson({ heading="Invoice" text="Du må først legge til kontatpersoner før du kan se dem her." /> - + ) } @@ -70,10 +57,11 @@ export default async function ContactPerson({ heading={tenantDetails.name} text="Detaljer om kontaktpersonene." /> - + ) } catch (error) { + console.error("Error in InvoicePage:", error) return ( diff --git a/apps/www/src/components/tenant/TenantSendInvoice.tsx b/apps/www/src/components/tenant/TenantSendInvoice.tsx index 4fd456a..8a4ffcc 100644 --- a/apps/www/src/components/tenant/TenantSendInvoice.tsx +++ b/apps/www/src/components/tenant/TenantSendInvoice.tsx @@ -4,13 +4,27 @@ import { useEffect, useState } from "react" import { zodResolver } from "@hookform/resolvers/zod" import { addDays, differenceInCalendarDays, format } from "date-fns" import { nb } from "date-fns/locale" -import { CalendarIcon, PlusIcon, SendIcon } from "lucide-react" +import { + CalendarIcon, + Check, + ChevronsUpDown, + PlusIcon, + SendIcon, +} from "lucide-react" import { useForm, useWatch } from "react-hook-form" import { toast } from "sonner" import { z } from "zod" import { Button } from "@dingify/ui/components/button" import { Calendar } from "@dingify/ui/components/calendar" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@dingify/ui/components/command" import { Form, FormControl, @@ -26,13 +40,6 @@ import { PopoverContent, PopoverTrigger, } from "@dingify/ui/components/popover" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@dingify/ui/components/select" import { Separator } from "@dingify/ui/components/separator" import { Textarea } from "@dingify/ui/components/textarea" @@ -58,6 +65,13 @@ const InvoiceSchema = z.object({ }) export default function TenantSendInvoice({ customers, products }) { + console.log("Customers in TenantSendInvoice:", customers) + console.log("Products in TenantSendInvoice:", products) + + // Extract the actual customer and product arrays + const customerArray = customers?.message || [] + const productArray = products?.message || [] + const today = new Date() const fourteenDaysFromToday = addDays(today, 14) @@ -161,35 +175,71 @@ export default function TenantSendInvoice({ customers, products }) { control={form.control} name="customer" render={({ field }) => ( - + Kunde - - - + + + + + + + + + + + + Ingen kunder funnet. + + + {customerArray.map((customer) => ( + { + form.setValue( + "customer", + customer.Id.toString(), + ) + form.setValue( + "email", + customer.EmailAddress, + ) + }} + > + + {customer.Name} + + {customer.OrganizationNumber} + + + ))} + + + + + )} @@ -273,33 +323,68 @@ export default function TenantSendInvoice({ customers, products }) { control={form.control} name="product" render={({ field }) => ( - + Produkter - - - + + + + + + + + + + + + Ingen produkter funnet. + + + {productArray.map((product) => ( + { + form.setValue( + "product", + product.Id.toString(), + ) + form.setValue( + "price", + product.SalesPrice, + ) + }} + > + + {product.Name} + + ))} + + + + + )} diff --git a/apps/www/src/lib/poweroffice-sdk.ts b/apps/www/src/lib/poweroffice-sdk.ts index 9298c38..a1cbe03 100644 --- a/apps/www/src/lib/poweroffice-sdk.ts +++ b/apps/www/src/lib/poweroffice-sdk.ts @@ -53,6 +53,17 @@ class PowerOfficeSDK { ) return response.data } + + async getCustomersAndProducts() { + const [customersResponse, productsResponse] = await Promise.all([ + this.api.get("/api/internal/poweroffice/customers"), + this.api.get("/api/internal/poweroffice/products"), + ]) + return { + customers: customersResponse.data, + products: productsResponse.data, + } + } } const apiUrl = @@ -60,11 +71,6 @@ const apiUrl = ? "https://api.vegard.workers.dev" : "https://api.vegard.workers.dev" -// const apiUrl = -// process.env.NODE_ENV === "production" -// ? "https://api.propdock.workers.dev" -// : "http://localhost:8787" - const apiKey = process.env.NEXT_PUBLIC_API_KEY || "super-secret" export const poweroffice = new PowerOfficeSDK(apiUrl, apiKey) From 42b62b509c58aa56497a44754347266e406dcb04 Mon Sep 17 00:00:00 2001 From: meglerhagen Date: Tue, 6 Aug 2024 19:43:36 +0200 Subject: [PATCH 5/9] update on project --- apps/www/.content-collections/generated/index.js | 2 +- apps/www/src/components/tenant/TenantSendInvoice.tsx | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/www/.content-collections/generated/index.js b/apps/www/.content-collections/generated/index.js index 12d4019..b49cf60 100644 --- a/apps/www/.content-collections/generated/index.js +++ b/apps/www/.content-collections/generated/index.js @@ -1,4 +1,4 @@ -// generated by content-collections at Tue Aug 06 2024 17:52:21 GMT+0200 (Central European Summer Time) +// generated by content-collections at Tue Aug 06 2024 17:55:51 GMT+0200 (Central European Summer Time) import allBlogPosts from "./allBlogPosts.js"; import allChangelogPosts from "./allChangelogPosts.js"; diff --git a/apps/www/src/components/tenant/TenantSendInvoice.tsx b/apps/www/src/components/tenant/TenantSendInvoice.tsx index 8a4ffcc..fd73b8e 100644 --- a/apps/www/src/components/tenant/TenantSendInvoice.tsx +++ b/apps/www/src/components/tenant/TenantSendInvoice.tsx @@ -378,6 +378,9 @@ export default function TenantSendInvoice({ customers, products }) { )} /> {product.Name} + + {product.SalesPrice} NOK + ))} From 1c60c6cd0f4b82fdd2b4e3a21f8d9564d09d5f54 Mon Sep 17 00:00:00 2001 From: meglerhagen Date: Tue, 6 Aug 2024 23:19:55 +0200 Subject: [PATCH 6/9] working --- .../.content-collections/generated/index.js | 2 +- apps/www/src/actions/create-invoice.ts | 14 ++++ .../app/(tenant)/tenant/[id]/invoice/page.tsx | 1 + .../components/tenant/TenantSendInvoice.tsx | 79 +++++++++++-------- 4 files changed, 63 insertions(+), 33 deletions(-) create mode 100644 apps/www/src/actions/create-invoice.ts diff --git a/apps/www/.content-collections/generated/index.js b/apps/www/.content-collections/generated/index.js index b49cf60..76ad1a8 100644 --- a/apps/www/.content-collections/generated/index.js +++ b/apps/www/.content-collections/generated/index.js @@ -1,4 +1,4 @@ -// generated by content-collections at Tue Aug 06 2024 17:55:51 GMT+0200 (Central European Summer Time) +// generated by content-collections at Tue Aug 06 2024 23:19:41 GMT+0200 (Central European Summer Time) import allBlogPosts from "./allBlogPosts.js"; import allChangelogPosts from "./allChangelogPosts.js"; diff --git a/apps/www/src/actions/create-invoice.ts b/apps/www/src/actions/create-invoice.ts new file mode 100644 index 0000000..cce2858 --- /dev/null +++ b/apps/www/src/actions/create-invoice.ts @@ -0,0 +1,14 @@ +"use server" + +import { poweroffice } from "@/lib/poweroffice-sdk" + +export async function createInvoice(invoiceData: any) { + console.log("invoiceData", invoiceData) + try { + const invoice = await poweroffice.createInvoice(invoiceData) + return { success: true, data: invoice } + } catch (error) { + console.error("Error creating invoice:", error) + return { success: false, error: error.message } + } +} diff --git a/apps/www/src/app/(tenant)/tenant/[id]/invoice/page.tsx b/apps/www/src/app/(tenant)/tenant/[id]/invoice/page.tsx index 218383b..4319599 100644 --- a/apps/www/src/app/(tenant)/tenant/[id]/invoice/page.tsx +++ b/apps/www/src/app/(tenant)/tenant/[id]/invoice/page.tsx @@ -1,3 +1,4 @@ +import { createInvoice } from "@/actions/create-invoice" import { getTenantDetails } from "@/actions/get-tenant-details" import { Settings } from "lucide-react" diff --git a/apps/www/src/components/tenant/TenantSendInvoice.tsx b/apps/www/src/components/tenant/TenantSendInvoice.tsx index fd73b8e..5d41593 100644 --- a/apps/www/src/components/tenant/TenantSendInvoice.tsx +++ b/apps/www/src/components/tenant/TenantSendInvoice.tsx @@ -1,6 +1,7 @@ "use client" import { useEffect, useState } from "react" +import { createInvoice } from "@/actions/create-invoice" import { zodResolver } from "@hookform/resolvers/zod" import { addDays, differenceInCalendarDays, format } from "date-fns" import { nb } from "date-fns/locale" @@ -64,10 +65,13 @@ const InvoiceSchema = z.object({ comment: z.string().optional(), }) -export default function TenantSendInvoice({ customers, products }) { - console.log("Customers in TenantSendInvoice:", customers) - console.log("Products in TenantSendInvoice:", products) - +export default function TenantSendInvoice({ + customers, + products, +}: { + customers: any + products: any +}) { // Extract the actual customer and product arrays const customerArray = customers?.message || [] const productArray = products?.message || [] @@ -107,33 +111,40 @@ export default function TenantSendInvoice({ customers, products }) { date && dueDate ? differenceInCalendarDays(dueDate, date) : 0 const onSubmit = async (data) => { - const promise = new Promise((resolve, reject) => { - setTimeout(() => { - const isSuccess = true - if (isSuccess) { - resolve({ name: "Invoice" }) - } else { - reject("Error creating invoice") - } - }, 2000) - }) - - toast.promise(promise, { - loading: "Vennligst vent...", - success: (data) => { - return `Faktura er blitt sendt` - }, - error: "Error", - }) + try { + const invoiceData = { + CurrencyCode: "NOK", + CustomerId: parseInt(data.customer), + SalesOrderLines: [ + { + Description: + productArray.find((p) => p.Id.toString() === data.product) + ?.Name || "", + ProductId: parseInt(data.product), + }, + ], + // Commenting out the following fields for now: + // InvoiceDate: format(data.date, "yyyy-MM-dd"), + // DueDate: format(data.dueDate, "yyyy-MM-dd"), + // YourReference: data.ourReference, + // TheirReference: data.theirReference, + // OrderNumber: data.orderReference, + // InvoiceEmail: data.invoiceEmail, + // BankAccountNumber: data.accountNumber, + // Comments: data.comment, + } - promise - .then(() => { - console.log(data) - form.reset() - }) - .catch((error) => { - console.error(error) - }) + const result = await createInvoice(invoiceData) + if (result.success) { + console.log("Created invoice:", result.data) + toast.success("Invoice created successfully!") + } else { + throw new Error(result.error) + } + } catch (error) { + console.error("Error creating invoice:", error) + toast.error("Failed to create invoice: " + error.message) + } } return ( @@ -219,6 +230,10 @@ export default function TenantSendInvoice({ customers, products }) { "email", customer.EmailAddress, ) + form.setValue( + "invoiceEmail", + customer.EmailAddress, + ) }} > ( - Faktura + Faktura epost From 0f824924e4d469e7d4985c2c5a3e9da9b9186c01 Mon Sep 17 00:00:00 2001 From: meglerhagen Date: Wed, 7 Aug 2024 07:44:16 +0200 Subject: [PATCH 7/9] working invoice --- .../.content-collections/generated/index.js | 2 +- .../components/tenant/TenantSendInvoice.tsx | 66 +++++++++---------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/apps/www/.content-collections/generated/index.js b/apps/www/.content-collections/generated/index.js index 0268626..82fccdc 100644 --- a/apps/www/.content-collections/generated/index.js +++ b/apps/www/.content-collections/generated/index.js @@ -1,4 +1,4 @@ -// generated by content-collections at Tue Aug 06 2024 13:21:25 GMT+0200 (Central European Summer Time) +// generated by content-collections at Wed Aug 07 2024 07:44:05 GMT+0200 (Central European Summer Time) import allBlogPosts from "./allBlogPosts.js"; import allChangelogPosts from "./allChangelogPosts.js"; diff --git a/apps/www/src/components/tenant/TenantSendInvoice.tsx b/apps/www/src/components/tenant/TenantSendInvoice.tsx index 5d41593..f0f0577 100644 --- a/apps/www/src/components/tenant/TenantSendInvoice.tsx +++ b/apps/www/src/components/tenant/TenantSendInvoice.tsx @@ -111,40 +111,40 @@ export default function TenantSendInvoice({ date && dueDate ? differenceInCalendarDays(dueDate, date) : 0 const onSubmit = async (data) => { - try { - const invoiceData = { - CurrencyCode: "NOK", - CustomerId: parseInt(data.customer), - SalesOrderLines: [ - { - Description: - productArray.find((p) => p.Id.toString() === data.product) - ?.Name || "", - ProductId: parseInt(data.product), - }, - ], - // Commenting out the following fields for now: - // InvoiceDate: format(data.date, "yyyy-MM-dd"), - // DueDate: format(data.dueDate, "yyyy-MM-dd"), - // YourReference: data.ourReference, - // TheirReference: data.theirReference, - // OrderNumber: data.orderReference, - // InvoiceEmail: data.invoiceEmail, - // BankAccountNumber: data.accountNumber, - // Comments: data.comment, - } - - const result = await createInvoice(invoiceData) - if (result.success) { - console.log("Created invoice:", result.data) - toast.success("Invoice created successfully!") - } else { - throw new Error(result.error) - } - } catch (error) { - console.error("Error creating invoice:", error) - toast.error("Failed to create invoice: " + error.message) + const invoiceData = { + CurrencyCode: "NOK", + CustomerId: parseInt(data.customer), + SalesOrderLines: [ + { + Description: + productArray.find((p) => p.Id.toString() === data.product)?.Name || + "", + ProductId: parseInt(data.product), + Quantity: data.quantity, + ProductUnitPrice: data.price, + }, + ], + InvoiceDate: format(data.date, "yyyy-MM-dd"), + DueDate: format(data.dueDate, "yyyy-MM-dd"), + YourReference: data.ourReference, + TheirReference: data.theirReference, + OrderNumber: data.orderReference, + InvoiceEmail: data.invoiceEmail, + BankAccountNumber: data.accountNumber, + Comments: data.comment, } + + toast.promise(createInvoice(invoiceData), { + loading: "Oppretter faktura...", + success: (result) => { + console.log("Opprettet faktura:", result.data) + return "Faktura opprettet vellykket!" + }, + error: (error) => { + console.error("Feil ved oppretting av faktura:", error) + return `Kunne ikke opprette faktura: ${error.message}` + }, + }) } return ( From dc780f91c04884fc1ccd09a4509030485837ad2b Mon Sep 17 00:00:00 2001 From: meglerhagen Date: Wed, 7 Aug 2024 08:14:16 +0200 Subject: [PATCH 8/9] working send invoice PO --- .../.content-collections/generated/index.js | 2 +- .../app/(tenant)/tenant/[id]/invoice/page.tsx | 46 +++++++++++++++---- .../components/tenant/TenantSendInvoice.tsx | 32 +++++++------ 3 files changed, 55 insertions(+), 25 deletions(-) diff --git a/apps/www/.content-collections/generated/index.js b/apps/www/.content-collections/generated/index.js index 82fccdc..e599ed4 100644 --- a/apps/www/.content-collections/generated/index.js +++ b/apps/www/.content-collections/generated/index.js @@ -1,4 +1,4 @@ -// generated by content-collections at Wed Aug 07 2024 07:44:05 GMT+0200 (Central European Summer Time) +// generated by content-collections at Wed Aug 07 2024 08:13:51 GMT+0200 (Central European Summer Time) import allBlogPosts from "./allBlogPosts.js"; import allChangelogPosts from "./allChangelogPosts.js"; diff --git a/apps/www/src/app/(tenant)/tenant/[id]/invoice/page.tsx b/apps/www/src/app/(tenant)/tenant/[id]/invoice/page.tsx index 4319599..ec0441d 100644 --- a/apps/www/src/app/(tenant)/tenant/[id]/invoice/page.tsx +++ b/apps/www/src/app/(tenant)/tenant/[id]/invoice/page.tsx @@ -1,18 +1,13 @@ -import { createInvoice } from "@/actions/create-invoice" +import Link from "next/link" import { getTenantDetails } from "@/actions/get-tenant-details" -import { Settings } from "lucide-react" +import { getWsApiKeys } from "@/actions/get-ws-api-keys" +import { getServerSession } from "next-auth/next" import { Button } from "@dingify/ui/components/button" -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@dingify/ui/components/card" +import { authOptions } from "@/lib/auth" +import { prisma } from "@/lib/db" import { poweroffice } from "@/lib/poweroffice-sdk" -import { AddContactPersonSheet } from "@/components/buttons/AddContactPersonSheet" -import { EditContactPersonSheet } from "@/components/buttons/EditContactPersonSheet" import { DashboardHeader } from "@/components/dashboard/header" import { DashboardShell } from "@/components/dashboard/shell" import { EmptyPlaceholder } from "@/components/shared/empty-placeholder" @@ -36,9 +31,40 @@ export default async function InvoicePage({ ) } + const session = await getServerSession(authOptions) + + const user = await prisma.user.findUnique({ + where: { id: session?.user.id }, + select: { workspaceId: true }, + }) + try { const tenantDetails = await getTenantDetails(tenantId) const { customers, products } = await poweroffice.getCustomersAndProducts() + const { success, apiKeys } = await getWsApiKeys(user.workspaceId) + + if (!success || apiKeys.length === 0) { + return ( + + + + + + Legg til regnskapsprogram + + + Legg til regnskapsprogram for å sende faktura. + + + + + ) + } if (!tenantDetails || tenantDetails.contacts.length === 0) { return ( diff --git a/apps/www/src/components/tenant/TenantSendInvoice.tsx b/apps/www/src/components/tenant/TenantSendInvoice.tsx index f0f0577..6406e45 100644 --- a/apps/www/src/components/tenant/TenantSendInvoice.tsx +++ b/apps/www/src/components/tenant/TenantSendInvoice.tsx @@ -107,6 +107,14 @@ export default function TenantSendInvoice({ const vat = totalPrice * 0.25 const totalPriceWithVat = totalPrice + vat + const formatNOK = (amount: number) => { + return new Intl.NumberFormat("nb-NO", { + style: "decimal", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount) + } + const daysBetween = date && dueDate ? differenceInCalendarDays(dueDate, date) : 0 @@ -160,12 +168,12 @@ export default function TenantSendInvoice({ onClick={form.handleSubmit(onSubmit)} > - Send + Send faktura - + */}
@@ -394,7 +402,7 @@ export default function TenantSendInvoice({ /> {product.Name} - {product.SalesPrice} NOK + {formatNOK(product.SalesPrice)} NOK ))} @@ -433,7 +441,7 @@ export default function TenantSendInvoice({ name="price" render={({ field }) => ( - Pris + Brutto pris
- Sum - {totalPrice.toFixed(2)} NOK + Brutto pris + {formatNOK(totalPrice)} NOK
Mva - {vat.toFixed(2)} NOK -
-
- Rabatt - 0% + {formatNOK(vat)} NOK
- Sum - {totalPriceWithVat.toFixed(2)} NOK (inkl.mva) + Netto pris + {formatNOK(totalPriceWithVat)} NOK (inkl.mva)
From 5ef104f03dfaa3e0f567aec3e8ccc64f2db0a135 Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 7 Aug 2024 11:29:44 +0200 Subject: [PATCH 9/9] Squashed commit of the following: commit c4b968c914c37b304896e1129149d6206e949a48 Author: Mathias Date: Wed Aug 7 11:22:43 2024 +0200 testing commit 5f9da6c6a492d25f5528560dd3ed2e3ca203348a Merge: 8ea0ba9 11c7526 Author: Mathias Date: Wed Aug 7 07:56:00 2024 +0200 Merge commit '11c75261c76522780d9521265eef1245b3540e3e' into add-building-kartverk-api commit 8ea0ba985f1f85f508e27074af2962a66470a2ee Author: Mathias Date: Tue Aug 6 19:05:00 2024 +0200 build? commit 5a5f0b3e7e4bf9809226a4d3a69cd8d0fdf989c2 Author: Mathias Date: Tue Jul 30 22:08:52 2024 +0200 Form resets at select - failsafe commit f5f10a3175998111050590ecd08fec0ec06e898e Author: Mathias Date: Tue Jul 30 22:02:56 2024 +0200 Fixed some undefined prisma errors commit cea285abbc6fb94dbfb0fcc11cf387ef29414ede Author: Mathias Date: Tue Jul 30 21:48:48 2024 +0200 geo search for address + dropdown --- apps/api/src/auth/handler.js | 36 +++ apps/api/src/auth/unkey.js | 66 ++++++ apps/api/src/env.js | 12 + apps/api/src/index.js | 21 ++ apps/api/src/lib/db.js | 23 ++ apps/api/src/lib/dbExtension.js | 22 ++ apps/api/src/lib/generateApiKey.js | 5 + apps/api/src/lib/hono.js | 6 + apps/api/src/lib/localApiKeys.js | 98 ++++++++ apps/api/src/lib/parsePrismaError.js | 16 ++ apps/api/src/lib/poweroffice.js | 35 +++ apps/api/src/lib/poweroffice/auth.js | 124 ++++++++++ apps/api/src/lib/poweroffice/customers.js | 14 ++ apps/api/src/lib/poweroffice/invoice.js | 21 ++ apps/api/src/lib/poweroffice/products.js | 14 ++ apps/api/src/models/apiKeyService.js | 18 ++ apps/api/src/models/buildings.js | 81 +++++++ apps/api/src/models/properties.js | 65 ++++++ apps/api/src/models/types.js | 1 + apps/api/src/models/workspace.js | 10 + .../discord/sendDiscordNotification.js | 40 ++++ apps/api/src/routes/channels.js | 78 +++++++ apps/api/src/routes/events.js | 147 ++++++++++++ .../api/src/routes/external/authMiddleware.js | 82 +++++++ apps/api/src/routes/external/buildings.js | 78 +++++++ apps/api/src/routes/external/index.js | 15 ++ apps/api/src/routes/external/properties.js | 63 ++++++ .../api/src/routes/internal/authMiddleware.js | 36 +++ apps/api/src/routes/internal/index.js | 44 ++++ .../internal/oauth/poweroffice/index.js | 92 ++++++++ .../src/routes/internal/poweroffice/index.js | 82 +++++++ .../src/routes/internal/tripletex/index.js | 211 ++++++++++++++++++ apps/api/src/routes/projects.js | 69 ++++++ apps/api/src/routes/users.js | 58 +++++ apps/api/src/types.js | 1 + apps/api/src/validators/index.js | 125 +++++++++++ apps/api/src/validators/types.js | 10 + apps/api/src/zod/index.js | 70 ++++++ .../.content-collections/cache/mapping.json | 2 +- .../generated/allBlogPosts.js | 4 +- .../generated/allChangelogPosts.js | 2 +- .../generated/allCustomersPosts.js | 2 +- .../generated/allHelpPosts.js | 2 +- .../.content-collections/generated/index.js | 2 +- apps/www/.gitignore | 2 + .../components/buttons/AddBuildingSheet.tsx | 138 +++++++++++- apps/www/src/lib/address-search.ts | 9 + 47 files changed, 2134 insertions(+), 18 deletions(-) create mode 100644 apps/api/src/auth/handler.js create mode 100644 apps/api/src/auth/unkey.js create mode 100644 apps/api/src/env.js create mode 100644 apps/api/src/index.js create mode 100644 apps/api/src/lib/db.js create mode 100644 apps/api/src/lib/dbExtension.js create mode 100644 apps/api/src/lib/generateApiKey.js create mode 100644 apps/api/src/lib/hono.js create mode 100644 apps/api/src/lib/localApiKeys.js create mode 100644 apps/api/src/lib/parsePrismaError.js create mode 100644 apps/api/src/lib/poweroffice.js create mode 100644 apps/api/src/lib/poweroffice/auth.js create mode 100644 apps/api/src/lib/poweroffice/customers.js create mode 100644 apps/api/src/lib/poweroffice/invoice.js create mode 100644 apps/api/src/lib/poweroffice/products.js create mode 100644 apps/api/src/models/apiKeyService.js create mode 100644 apps/api/src/models/buildings.js create mode 100644 apps/api/src/models/properties.js create mode 100644 apps/api/src/models/types.js create mode 100644 apps/api/src/models/workspace.js create mode 100644 apps/api/src/notifications/discord/sendDiscordNotification.js create mode 100644 apps/api/src/routes/channels.js create mode 100644 apps/api/src/routes/events.js create mode 100644 apps/api/src/routes/external/authMiddleware.js create mode 100644 apps/api/src/routes/external/buildings.js create mode 100644 apps/api/src/routes/external/index.js create mode 100644 apps/api/src/routes/external/properties.js create mode 100644 apps/api/src/routes/internal/authMiddleware.js create mode 100644 apps/api/src/routes/internal/index.js create mode 100644 apps/api/src/routes/internal/oauth/poweroffice/index.js create mode 100644 apps/api/src/routes/internal/poweroffice/index.js create mode 100644 apps/api/src/routes/internal/tripletex/index.js create mode 100644 apps/api/src/routes/projects.js create mode 100644 apps/api/src/routes/users.js create mode 100644 apps/api/src/types.js create mode 100644 apps/api/src/validators/index.js create mode 100644 apps/api/src/validators/types.js create mode 100644 apps/api/src/zod/index.js create mode 100644 apps/www/src/lib/address-search.ts diff --git a/apps/api/src/auth/handler.js b/apps/api/src/auth/handler.js new file mode 100644 index 0000000..94d645c --- /dev/null +++ b/apps/api/src/auth/handler.js @@ -0,0 +1,36 @@ +import { verifyKey } from '@unkey/api'; +const DEBUG = false; // NB! Change to false before committing. +const FE_KEY = "super-secret"; // TODO: get from wrangler.toml +export async function verifyApiKey(key, dummy = false) { + if (DEBUG) { + console.debug("Middleware debug - processing key:", key); + } + if (dummy) { + const res = false; + if (DEBUG) { + console.debug("Middleware debug - returning API verified as:", res); + } + return res; + } + const { result, error } = await verifyKey(key); + if (DEBUG) { + console.debug("Middleware debug - verify-key results:", result?.code, result?.valid); + } + if (result) { + return result.valid; + } + else if (error) { + console.error("Couldn't verify key:", error.code, error.message); + } + return false; +} +export async function verifyFrontend(pass) { + if (DEBUG) { + console.debug("Middleware debug - processing pass:", pass); + } + const res = (pass == FE_KEY); + if (DEBUG) { + console.debug("Middleware debug - returning API verified as:", res); + } + return res; +} diff --git a/apps/api/src/auth/unkey.js b/apps/api/src/auth/unkey.js new file mode 100644 index 0000000..fbd91a8 --- /dev/null +++ b/apps/api/src/auth/unkey.js @@ -0,0 +1,66 @@ +// const UNKEY_ROOT = process.env.UNKEY_ROOT +// const API_ID = process.env.UNKEY_API_ID +async function createAPIKey(rk, aId, workspaceId, serviceName = "", prefix = "") { + const options = { + method: 'POST', + headers: { Authorization: `Bearer ${rk}`, 'Content-Type': 'application/json' }, + body: ` + {"apiId":${aId}, + "prefix":${prefix}, + "ownerId":${workspaceId}, + "name":${serviceName}, + "meta":{ + "ratelimit":{ + "type":"fast", + "limit":10, + "duration":60000 + }, + "enabled":true, + }` + }; + try { + const res = await fetch('https://api.unkey.dev/v1/keys.createKey', options); + const result = await res.json(); + console.debug(`Tried to create API key for workspace <${workspaceId}>, returned with status <${res.status}>`); + return result.key; + } + catch (error) { + console.error(`${error.code}: ${error.message}`); + return null; + } +} +export { createAPIKey }; +{ /* +body: ` +{"apiId":${API_ID}, +"prefix":"", +"name":"my key", +"byteLength":135, +"ownerId":"team_123", +"meta":{ + "billingTier":"PRO", + "trialEnds":"2023-06-16T17:16:37.161Z"}, + "roles":[ + "admin", + "finance" + ], + "permissions":[ + "domains.create_record", + "say_hello" + ], + "expires":1623869797161, + "remaining":1000, + "refill":{ + "interval":"daily", + "amount":100 + }, + "ratelimit":{ + "type":"fast", + "limit":10, + "duration":60000 + }, + "enabled":true, + "environment":"" +}` +*/ +} diff --git a/apps/api/src/env.js b/apps/api/src/env.js new file mode 100644 index 0000000..2615596 --- /dev/null +++ b/apps/api/src/env.js @@ -0,0 +1,12 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import { z } from "zod"; +export const zEnv = z.object({ + DATABASE_URL: z.string(), + ENVIRONMENT: z + .enum(["development", "preview", "production"]) + .default("development"), + PO_ROOT: z.string(), + PO_SUB_KEY: z.string(), + PO_APP_KEY: z.string(), + PO_ONBOARD_REDIRECT: z.string(), +}); diff --git a/apps/api/src/index.js b/apps/api/src/index.js new file mode 100644 index 0000000..fd0eef5 --- /dev/null +++ b/apps/api/src/index.js @@ -0,0 +1,21 @@ +import { zEnv } from "./env"; +import internal from "./routes/internal"; +import external from "./routes/external"; +import { honoFactory } from "./lib/hono"; +const app = honoFactory(); +// Main-level routes +app.route("/api/external", external); +app.route("/api/internal", internal); +export default { + fetch: (req, env, exCtx) => { + const parsedEnv = zEnv.safeParse(env); + if (!parsedEnv.success) { + return Response.json({ + code: "BAD_ENVIRONMENT", + message: "Some environment variables are missing or are invalid", + errors: parsedEnv.error, + }, { status: 500 }); + } + return app.fetch(req, parsedEnv.data, exCtx); + }, +}; diff --git a/apps/api/src/lib/db.js b/apps/api/src/lib/db.js new file mode 100644 index 0000000..6a34dab --- /dev/null +++ b/apps/api/src/lib/db.js @@ -0,0 +1,23 @@ +import { PrismaClient, PrismaNeon, Pool } from "@dingify/db"; +const pool = (env) => new Pool({ connectionString: env.DATABASE_URL }); +const adapter = (env) => new PrismaNeon(pool(env)); +const createPrismaClient = (env) => { + // Check if prisma client is already instantiated in global context + const globalPrisma = globalThis; + const existingPrismaClient = globalPrisma.prisma; + if (existingPrismaClient) { + return existingPrismaClient; + } + const prismaClient = new PrismaClient({ + adapter: adapter(env), + log: env.ENVIRONMENT === "development" + ? ["error", "warn"] + : ["error"], + errorFormat: "pretty", + }); + if (env.ENVIRONMENT !== "production") { + globalPrisma.prisma = prismaClient; + } + return prismaClient; +}; +export const prisma = (env) => createPrismaClient(env); diff --git a/apps/api/src/lib/dbExtension.js b/apps/api/src/lib/dbExtension.js new file mode 100644 index 0000000..cdbeed9 --- /dev/null +++ b/apps/api/src/lib/dbExtension.js @@ -0,0 +1,22 @@ +import { Prisma } from '@prisma/client'; +/** + * Extends Prisma with a filter on all queries such that the result will return only + * instances belonging to the same workspace as the user. + * + * Requires `user` to have a workspace, and the query must target a model that has + * `workspaceId` in its schema. The query must additionally not have `workspaceId` + * in its `where`-clause already. + */ +function workspaceExtension(user) { + const workspaceId = user.workspaceId; + const ext = Prisma.defineExtension({ + query: { + $allOperations({ model, operation, args, query }) { + args.where = { ...args.where, workspaceId: workspaceId }; + return query(args); + } + } + }); + return ext; +} +export { workspaceExtension }; diff --git a/apps/api/src/lib/generateApiKey.js b/apps/api/src/lib/generateApiKey.js new file mode 100644 index 0000000..0efb4e5 --- /dev/null +++ b/apps/api/src/lib/generateApiKey.js @@ -0,0 +1,5 @@ +export function generateApiKey() { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/apps/api/src/lib/hono.js b/apps/api/src/lib/hono.js new file mode 100644 index 0000000..0a6f947 --- /dev/null +++ b/apps/api/src/lib/hono.js @@ -0,0 +1,6 @@ +import { Hono } from "hono"; +function honoFactory() { + const app = new Hono(); + return app; +} +export { honoFactory }; diff --git a/apps/api/src/lib/localApiKeys.js b/apps/api/src/lib/localApiKeys.js new file mode 100644 index 0000000..22a372f --- /dev/null +++ b/apps/api/src/lib/localApiKeys.js @@ -0,0 +1,98 @@ +import { prisma } from "../lib/db"; +async function getUserApiKeyFull(env, userId, serviceName) { + const db = prisma(env); + let apiKey; + try { + apiKey = await db.userApiKey.findFirst({ + where: { + userId: userId, + serviceName: serviceName, + } + }); + } + catch (error) { + console.log("Error fetching user API key:", error); + return null; + } + return apiKey; +} +async function getUserApiKey(env, userId, serviceName) { + const fullKey = await getUserApiKeyFull(env, userId, serviceName); + let res = null; + if (fullKey) { + res = fullKey.secret; + } + return res; +} +async function getWSApiKeyFull(env, workspaceId, serviceName) { + const db = prisma(env); + let apiKey; + try { + apiKey = await db.wSApiKey.findFirst({ + where: { + workspaceId: workspaceId, + serviceName: serviceName, + } + }); + } + catch (error) { + console.log("Error fetching user API key:", error); + return null; + } + return apiKey; +} +async function getWorkspaceApiKey(env, workspaceId, serviceName) { + const fullKey = await getWSApiKeyFull(env, workspaceId, serviceName); + let res = null; + if (fullKey) { + res = fullKey.secret; + } + return res; +} +async function storeWorkspaceAccessToken(db, workspaceId, serviceName, token, expiry) { + const now = new Date(); + const expiryTime = (expiry * 1000) - 5000; // Shave 5 seconds off of the duration to compensate for roundtrip + db latency + const saveTime = new Date(now.getTime() + expiryTime); + try { + await db.workspaceAccessToken.upsert({ + where: { + workspaceId_serviceName: { + workspaceId: workspaceId, + serviceName: serviceName, + } + }, + update: { + secret: token, + validTo: saveTime, + }, + create: { + workspaceId: workspaceId, + serviceName: serviceName, + secret: token, + validTo: saveTime, + } + }); + } + catch (error) { + console.log(`Error saving access token (${serviceName}:${workspaceId}):`, error); + } +} +async function getWorkspaceAccessToken(db, workspaceId, serviceName) { + let accessToken = await db.workspaceAccessToken.findFirst({ + where: { + workspaceId: workspaceId, + serviceName: serviceName, + } + }); + if (!accessToken) { + console.debug("Returning null"); + return null; + } + const now = new Date(); + const adjustedTime = new Date(now.getTime() + 5000); + if (accessToken.validTo > adjustedTime) { + return accessToken; + } + return null; +} +export { getUserApiKey, getWorkspaceApiKey, getWorkspaceAccessToken, storeWorkspaceAccessToken, }; diff --git a/apps/api/src/lib/parsePrismaError.js b/apps/api/src/lib/parsePrismaError.js new file mode 100644 index 0000000..fd284fe --- /dev/null +++ b/apps/api/src/lib/parsePrismaError.js @@ -0,0 +1,16 @@ +export function parsePrismaError(error) { + // A simple error message parser that looks for missing arguments + const missingArgumentMatch = error.message.match(/Argument `(\w+)` is missing./); + if (missingArgumentMatch) { + let fieldName = missingArgumentMatch[1]; + let baseMessage = `The '${fieldName}' field is required but was not provided.`; + // Specific instructions for known fields + if (fieldName === "channel") { + baseMessage += " You need to add 'channel' to your call to make it work."; + } + // Add more specific messages for other fields if necessary + return baseMessage; + } + // Default to returning the original error message if no known patterns are matched + return error.message; +} diff --git a/apps/api/src/lib/poweroffice.js b/apps/api/src/lib/poweroffice.js new file mode 100644 index 0000000..36885fa --- /dev/null +++ b/apps/api/src/lib/poweroffice.js @@ -0,0 +1,35 @@ +import { getRequestHeaders } from "@/lib/poweroffice/auth"; +async function superget(env, url, workspaceId) { + const poHeaders = await getRequestHeaders(env, workspaceId); + const response = await fetch(url, { + method: "GET", + headers: poHeaders, + }); + if (!response.ok) { + throw new Error(`Bad response while fetching ${url}: ${response.status} ${response.statusText}`); + } + const res = await response.json(); + return res; +} +async function superpost(env, url, workspaceId, data) { + const poHeaders = await getRequestHeaders(env, workspaceId); + let response; + try { + response = await fetch(url, { + method: "POST", + headers: poHeaders, + body: JSON.stringify(data), + }); + } + catch (error) { + console.error("Superpost error:", error); + return; + } + if (!response.ok) { + console.error(`Bad response while posting to ${url}: ${response.status} ${response.statusText} ${await response.text()}`); + throw new Error(`Bad response while posting to ${url}: ${response.status} ${response.statusText}`); + } + const res = await response.json(); + return res; +} +export { superget, superpost, }; diff --git a/apps/api/src/lib/poweroffice/auth.js b/apps/api/src/lib/poweroffice/auth.js new file mode 100644 index 0000000..2bef307 --- /dev/null +++ b/apps/api/src/lib/poweroffice/auth.js @@ -0,0 +1,124 @@ +import { prisma } from "../../lib/db"; +import { getWorkspaceApiKey, getWorkspaceAccessToken, storeWorkspaceAccessToken } from "../localApiKeys"; +const PO_ROOT_DEMO = "https://goapi.poweroffice.net/Demo/v2"; +const PO_ROOT_PROD = "https://goapi.poweroffice.net/v2"; +const PO_AUTH_DEMO = "https://goapi.poweroffice.net/Demo/OAuth/Token"; +const PO_AUTH_PROD = "https://goapi.poweroffice.net/OAuth/Token"; +const PO_ROOT = PO_ROOT_DEMO; +const PO_AUTH = PO_AUTH_DEMO; +const PO_ONBOARDING_START = `${PO_ROOT}/onboarding/initiate`; +const PO_ONBOARDING_FINAL = `${PO_ROOT}/onboarding/finalize`; +function getOnboardingHeaders(env) { + const headers = { + "Content-Type": "application/json", + "Ocp-Apim-Subscription-Key": env.PO_SUB_KEY, + //"ClientOrganizationNo": "", + }; + return headers; +} +function getOnboardingBody(env) { + const body = { + "ApplicationKey": env.PO_APP_KEY, + "RedirectUri": env.PO_ONBOARD_REDIRECT, + }; + return body; +} +async function exchangeCodeForKey(env, code) { + const url = PO_ONBOARDING_FINAL; + const headers = getOnboardingHeaders(env); + const body = { + "OnboardingToken": code, + }; + try { + const response = await fetch(url, { + method: "POST", + headers: headers, + body: JSON.stringify(body) + }); + if (response.ok) { + const responseData = await response.json(); + const key = responseData["OnboardedClientsInformation"][0]["ClientKey"]; + return key; + } + else { + console.error(`Error: ${response.statusText}`); + throw Error(response.statusText); + } + } + catch (error) { + console.error(`Network error: ${error.message}`); + throw error; + } +} +function getAuthHeaders(env, client_key) { + const authKeyRaw = `${env.PO_APP_KEY}:${client_key}`; + const auth_64 = btoa(authKeyRaw); + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + "Ocp-Apim-Subscription-Key": env.PO_SUB_KEY, + 'Authorization': `Basic ${auth_64}` + }; + return headers; +} +async function getAccessToken(env, workspaceId) { + const db = prisma(env); + const serviceName = "poweroffice"; + // Check for token in db + let accessTokenDb = await getWorkspaceAccessToken(db, workspaceId, serviceName); + if (accessTokenDb) { + return accessTokenDb.secret; + } + // There was no token in db OR the token was not valid anymore, so we continue + const secret = await getWorkspaceApiKey(env, workspaceId, serviceName); // Fetch the workspace's secret + // If the workspace has no secret for this service, throw an error + if (!secret) { + throw new Error(`(Workspace:${workspaceId}) Could not find client_secret for service:${serviceName}`); + } + // Query for new access token + let newTokenObject; + try { + newTokenObject = await getNewAccessToken(env, secret); + } + catch (error) { + throw new Error(`(Workspace:${workspaceId}) Could not get new access_token for service:${serviceName} => ${error}`); + } + // Save the new access token to db + storeWorkspaceAccessToken(db, workspaceId, serviceName, newTokenObject.secret, newTokenObject.expiry); + // Return the new access token + return newTokenObject.secret; +} +async function getNewAccessToken(env, client_key) { + const headers = getAuthHeaders(env, client_key); + const body = new URLSearchParams(); + body.append('grant_type', 'client_credentials'); + let res; + try { + const response = await fetch(PO_AUTH, { + method: "POST", + headers: headers, + body: body.toString() + }); + res = await response.json(); + if (response.status == 400) { + throw new Error(`${response.status}: ${response.statusText} => ${res.error}: ${res.error_description}`); + } + else if (response.status != 200) { + throw new Error(`${response.status}: ${response.statusText}`); + } + } + catch (error) { + console.error(`Error querying Poweroffice auth endpoint: ${error.message}`); + throw error; + } + return { "secret": res.access_token, "expiry": res.expires_in }; +} +async function getRequestHeaders(env, workspaceId) { + const token = await getAccessToken(env, workspaceId); + const headers = { + 'Content-Type': 'application/json', + "Ocp-Apim-Subscription-Key": env.PO_SUB_KEY, + 'Authorization': `Bearer ${token}` + }; + return headers; +} +export { PO_ROOT, PO_ONBOARDING_START, getOnboardingHeaders, getOnboardingBody, getAuthHeaders, exchangeCodeForKey, getAccessToken, getRequestHeaders }; diff --git a/apps/api/src/lib/poweroffice/customers.js b/apps/api/src/lib/poweroffice/customers.js new file mode 100644 index 0000000..9454df0 --- /dev/null +++ b/apps/api/src/lib/poweroffice/customers.js @@ -0,0 +1,14 @@ +import { PO_ROOT } from "@/lib/poweroffice/auth"; +import { superget } from "../poweroffice"; +const CUSTOMERS_URL = `${PO_ROOT}/Customers`; +async function getCustomers(env, workspaceId) { + const url = CUSTOMERS_URL; + const response = await superget(env, url, workspaceId); + return response; +} +async function getCustomer(env, workspaceId, customerId) { + const url = `${CUSTOMERS_URL}/${customerId}`; + const response = await superget(env, url, workspaceId); + return response; +} +export { getCustomers, getCustomer, }; diff --git a/apps/api/src/lib/poweroffice/invoice.js b/apps/api/src/lib/poweroffice/invoice.js new file mode 100644 index 0000000..9eabb7e --- /dev/null +++ b/apps/api/src/lib/poweroffice/invoice.js @@ -0,0 +1,21 @@ +import { superpost } from "../poweroffice"; +const INVOICE_URL = `https://goapi.poweroffice.net/Demo/v2/SalesOrders/Complete`; +// poCustomerId should maybe be a field on Tenant? +// Either as extCustomerId to be compatible with all integrations, or as a separate through-model (TenantPowerOfficeCustomer) +async function createSalesOrder(env, workspaceId, poCustomerId, productId, departmentId, projectId) { + // POST https://goapi.poweroffice.net/v2/SalesOrders/Complete + const url = INVOICE_URL; + const currencyCode = "NOK"; + const invoiceData = { + "CurrencyCode": currencyCode, + "CustomerId": poCustomerId, + "SalesOrderLines": [{ + "Description": "Faktura sendt via Propdock", + "ProductId": productId, + }], + "SalesOrderStatus": "Draft", + }; + const response = await superpost(env, url, workspaceId, invoiceData); + return response; +} +export { createSalesOrder, }; diff --git a/apps/api/src/lib/poweroffice/products.js b/apps/api/src/lib/poweroffice/products.js new file mode 100644 index 0000000..f96bb21 --- /dev/null +++ b/apps/api/src/lib/poweroffice/products.js @@ -0,0 +1,14 @@ +import { PO_ROOT } from "@/lib/poweroffice/auth"; +import { superget } from "../poweroffice"; +const PRODUCTS_URL = `${PO_ROOT}/Products`; +async function getProducts(env, workspaceId) { + const url = PRODUCTS_URL; + const response = await superget(env, url, workspaceId); + return response; +} +async function getProduct(env, workspaceId, customerId) { + const url = `${PRODUCTS_URL}/${customerId}`; + const response = await superget(env, url, workspaceId); + return response; +} +export { getProducts, getProduct, }; diff --git a/apps/api/src/models/apiKeyService.js b/apps/api/src/models/apiKeyService.js new file mode 100644 index 0000000..33afde5 --- /dev/null +++ b/apps/api/src/models/apiKeyService.js @@ -0,0 +1,18 @@ +// Function to get API key from the database +async function getAPIKey(db, workspaceId, serviceName) { + const apiKey = await db.wSApiKey.findFirst({ + where: { + workspaceId: workspaceId, + serviceName: serviceName, + isActive: true, + }, + select: { + secret: true, + }, + }); + if (!apiKey) { + throw new Error(`API key for service "${serviceName}" not found`); + } + return apiKey.secret; +} +export { getAPIKey }; diff --git a/apps/api/src/models/buildings.js b/apps/api/src/models/buildings.js new file mode 100644 index 0000000..aae5c11 --- /dev/null +++ b/apps/api/src/models/buildings.js @@ -0,0 +1,81 @@ +import { prisma } from "../lib/db"; +import { workspaceExtension } from "../lib/dbExtension"; +async function createBuilding(user, propertyId, buildingData, env) { + const db = prisma(env); + try { + const building = await db.building.create({ + data: { + workspaceId: user.workspaceId, + propertyId: propertyId, + ...buildingData + } + }); + return building; + } + catch (error) { + console.error(error); + throw error; + } +} +async function getAllWorkspaceBuildings(user, env) { + const _prisma = prisma(env); + const db = _prisma.$extends(workspaceExtension(user)); + try { + const buildings = await db.building.findMany(); + return buildings; + } + catch (error) { + console.error(error); + throw error; + } +} +async function getAllBuildingsByProperty(user, propertyId, env) { + const _prisma = prisma(env); + const db = _prisma.$extends(workspaceExtension(user)); + try { + const buildings = await db.building.findMany({ + where: { + propertyId: propertyId, + } + }); + return buildings; + } + catch (error) { + console.error(error); + throw error; + } +} +async function getWorkspaceBuildingById(user, buildingId, env) { + const _prisma = prisma(env); + const db = _prisma.$extends(workspaceExtension(user)); + try { + const building = await db.building.findUnique({ + where: { + id: buildingId, + } + }); + return building; + } + catch (error) { + console.error(error); + throw error; + } +} +async function editWorkspaceBuilding(user, buildingId, data, env) { + const _prisma = prisma(env); + const db = _prisma.$extends(workspaceExtension(user)); + try { + const building = await db.building.update({ + where: { + id: buildingId, + }, + data: data, + }); + return building; + } + catch (error) { + console.error(error); + throw error; + } +} +export { createBuilding, getAllBuildingsByProperty, getWorkspaceBuildingById, editWorkspaceBuilding, getAllWorkspaceBuildings }; diff --git a/apps/api/src/models/properties.js b/apps/api/src/models/properties.js new file mode 100644 index 0000000..7511c07 --- /dev/null +++ b/apps/api/src/models/properties.js @@ -0,0 +1,65 @@ +import { prisma } from "../lib/db"; +import { workspaceExtension } from "../lib/dbExtension"; +const DEBUG = false; // NB! Change to false before committing. +async function createProperty(user, name, type, env) { + const db = prisma(env); + try { + const property = await db.property.create({ + data: { + workspaceId: user.workspaceId, + type: type, + name: name, + } + }); + return property; + } + catch (error) { + throw error; + } +} +async function getAllWorkspaceProperties(user, env) { + const _prisma = prisma(env); + const db = _prisma.$extends(workspaceExtension(user)); + try { + const properties = await db.property.findMany({}); + return properties; + } + catch (error) { + console.error(error); + throw error; + } +} +async function getWorkspacePropertyById(user, propertyId, env) { + const _prisma = prisma(env); + const db = _prisma.$extends(workspaceExtension(user)); + try { + const property = await db.property.findUnique({ + where: { + id: propertyId, + } + }); + return property; + } + catch (error) { + console.error(error); + throw error; + } +} +async function editWorkspaceProperty(user, propertyId, data, env) { + const _prisma = prisma(env); + const db = _prisma.$extends(workspaceExtension(user)); + try { + const property = await db.property.update({ + where: { + id: propertyId, + }, + data: data, + }); + return property; + } + catch (error) { + console.error(error); + throw error; + } +} +export { createProperty, getAllWorkspaceProperties, getWorkspacePropertyById, editWorkspaceProperty }; diff --git a/apps/api/src/models/types.js b/apps/api/src/models/types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/apps/api/src/models/types.js @@ -0,0 +1 @@ +export {}; diff --git a/apps/api/src/models/workspace.js b/apps/api/src/models/workspace.js new file mode 100644 index 0000000..28e59c6 --- /dev/null +++ b/apps/api/src/models/workspace.js @@ -0,0 +1,10 @@ +async function saveAPIKey(db, workspaceId, key, serviceName) { + await db.wSApiKey.create({ + data: { + workspaceId: workspaceId, + serviceName: serviceName, + secret: key, + }, + }); +} +export { saveAPIKey }; diff --git a/apps/api/src/notifications/discord/sendDiscordNotification.js b/apps/api/src/notifications/discord/sendDiscordNotification.js new file mode 100644 index 0000000..b77d071 --- /dev/null +++ b/apps/api/src/notifications/discord/sendDiscordNotification.js @@ -0,0 +1,40 @@ +export async function sendDiscordNotification(webhook, message) { + try { + console.log("Sending Discord notification to webhook URL:", webhook); + const response = await fetch(webhook, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: message, + }), + }); + if (!response.ok) { + console.error("Failed to send Discord notification"); + } + } + catch (error) { + console.error("Error sending notification to Discord:", error); + } +} +export async function sendSlackNotification(webhook, message) { + try { + console.log("Sending Slack notification to webhook URL:", webhook); + const response = await fetch(webhook, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + text: message, + }), + }); + if (!response.ok) { + console.error("Failed to send Slack notification"); + } + } + catch (error) { + console.error("Error sending notification to Slack:", error); + } +} diff --git a/apps/api/src/routes/channels.js b/apps/api/src/routes/channels.js new file mode 100644 index 0000000..9d7e265 --- /dev/null +++ b/apps/api/src/routes/channels.js @@ -0,0 +1,78 @@ +import { Hono } from "hono"; +import { prisma } from "../lib/db"; +const channels = new Hono(); +channels.post("/", async (c) => { + const apiKey = c.req.header("x-api-key"); + if (!apiKey) { + return c.json({ ok: false, message: "API key is required" }, 401); + } + const user = await prisma(c.env).user.findUnique({ + where: { apiKey }, + }); + if (!user) { + return c.json({ ok: false, message: "Invalid API key" }, 401); + } + const { project, name } = await c.req.json(); + if (!project) { + return c.json({ ok: false, message: "Project name is required" }, 400); + } + const projectExists = await prisma(c.env).project.findFirst({ + where: { + name: project, + userId: user.id, + }, + }); + if (!projectExists) { + return c.json({ + ok: false, + message: "Project not found or does not belong to the user", + }, 404); + } + const channelExists = await prisma(c.env).channel.findFirst({ + where: { + name, + projectId: projectExists.id, + }, + }); + if (channelExists) { + return c.json({ + ok: false, + message: "Channel with this name already exists in the project", + }, 409); + } + const channel = await prisma(c.env).channel.create({ + data: { + name, + projectId: projectExists.id, + }, + }); + return c.json({ ok: true, message: "Channel created", channel }); +}); +channels.get("/", async (c) => { + const apiKey = c.req.header("x-api-key"); + if (!apiKey) { + return c.json({ ok: false, message: "API key is required" }, 401); + } + const user = await prisma(c.env).user.findUnique({ + where: { apiKey }, + }); + if (!user) { + return c.json({ ok: false, message: "Invalid API key" }, 401); + } + const projects = await prisma(c.env).project.findMany({ + where: { userId: user.id }, + select: { id: true }, + }); + if (!projects || projects.length === 0) { + return c.json({ ok: false, message: "No projects found for the user" }, 404); + } + const projectIds = projects.map((project) => project.id); + const channels = await prisma(c.env).channel.findMany({ + where: { projectId: { in: projectIds } }, + }); + if (!channels || channels.length === 0) { + return c.json({ ok: false, message: "No channels found for the user's projects" }, 404); + } + return c.json({ ok: true, channels }); +}); +export default channels; diff --git a/apps/api/src/routes/events.js b/apps/api/src/routes/events.js new file mode 100644 index 0000000..4b591f2 --- /dev/null +++ b/apps/api/src/routes/events.js @@ -0,0 +1,147 @@ +import { Hono } from "hono"; +import { prisma } from "../lib/db"; +import { parsePrismaError } from "../lib/parsePrismaError"; +import { sendDiscordNotification, sendSlackNotification, } from "../notifications/discord/sendDiscordNotification"; +import { EventSchema } from "../validators"; +const events = new Hono(); +// POST - Create Event +events.post("/", async (c) => { + const apiKey = c.req.header("x-api-key"); + if (!apiKey) { + return c.json({ ok: false, message: "API key is required" }, 401); + } + // Find user by API key + const user = await prisma(c.env).user.findUnique({ + where: { apiKey }, + }); + if (!user) { + return c.json({ ok: false, message: "Invalid API key" }, 401); + } + try { + const eventData = EventSchema.parse(await c.req.json()); + // Destructure validated data + const { channel, name, icon, notify, tags, userId } = eventData; + // Find the user's project + const project = await prisma(c.env).project.findFirst({ + where: { + userId: user.id, + }, + }); + if (!project) { + return c.json({ + ok: false, + message: "No projects found for this user. Ensure the user has projects created.", + }, 404); + } + // Find the channel by name + const channelExists = await prisma(c.env).channel.findFirst({ + where: { + name: channel, + projectId: project.id, + }, + }); + if (!channelExists) { + const availableChannels = await prisma(c.env).channel.findMany({ + where: { projectId: project.id }, + select: { name: true }, + }); + const channelNames = availableChannels.map((ch) => ch.name).join(", "); + return c.json({ + ok: false, + message: `No channel found with the provided channel name. You need to add it on the website. These are your available channels: ${channelNames}`, + availableChannels: availableChannels.map((ch) => ch.name), + }, 404); + } + // Check if the customer exists based on user_id and project_id + let customer = await prisma(c.env).customer.findUnique({ + where: { + userId_projectId: { + userId: userId, + projectId: project.id, + }, + }, + }); + // If the customer does not exist, create a new customer + if (!customer) { + try { + customer = await prisma(c.env).customer.create({ + data: { + projectId: project.id, + userId: userId, + name: "", // Assuming name and email are optional + email: "", + createdAt: new Date(), + }, + }); + console.log("New customer created:", customer); // Log the new customer + } + catch (error) { + console.error("Error creating customer:", error); + throw error; + } + } + else { + console.log("Existing customer found:", customer); // Log the existing customer + } + // Create the event and associate it with the customer + const savedEvent = await prisma(c.env).event.create({ + data: { + name: name || "", + channelId: channelExists.id, + userId: userId, // userId here is a plain string + icon: icon || "", + notify, + tags: tags || {}, + customerId: customer.id, // Associate the event with the customer + }, + }); + console.log("New event created:", savedEvent); // Log the new event + // Update logs metrics for the project + const metrics = await prisma(c.env).metrics.findUnique({ + where: { projectId: project.id }, + }); + if (metrics) { + await prisma(c.env).metrics.update({ + where: { id: metrics.id }, + data: { + logsUsed: { increment: 1 }, + }, + }); + // Fetch the updated metrics and log them + const updatedMetrics = await prisma(c.env).metrics.findUnique({ + where: { id: metrics.id }, + }); + console.log("Updated metrics:", updatedMetrics); + } + else { + console.error("Metrics not found for the project"); + } + // Fetch notification settings for the current user + const notificationSettings = await prisma(c.env).notificationSetting.findFirst({ + where: { userId: user.id }, + }); + console.log("Notification settings:", notificationSettings); + if (notificationSettings) { + const { type, details } = notificationSettings; + const detailsParsed = details; + if (type === "DISCORD" && detailsParsed?.webhook) { + await sendDiscordNotification(detailsParsed.webhook, `Event created: ${name}`); + } + else if (type === "SLACK" && detailsParsed?.webhook) { + await sendSlackNotification(detailsParsed.webhook, `Event created: ${name}`); + } + } + else { + console.log("No notification settings found for the user."); + } + return c.json({ ok: true, message: "Event logged!", event: savedEvent }, 201); // Return 201 status code + } + catch (error) { + return c.json({ + ok: false, + message: "Failed to log event", + error: parsePrismaError(error), + }, 400); + } +}); +export default events; diff --git a/apps/api/src/routes/external/authMiddleware.js b/apps/api/src/routes/external/authMiddleware.js new file mode 100644 index 0000000..2087658 --- /dev/null +++ b/apps/api/src/routes/external/authMiddleware.js @@ -0,0 +1,82 @@ +import { prisma } from "../../lib/db"; +import { verifyApiKey } from "../../auth/handler"; +//const ENV_DEBUG: string | undefined = process.env?.DEBUG_MODE; // TODO: investigate if this can be made to work with wrangler +const DEBUG = false; // NB! Change to false before committing. +// Routes that should be exempt from auth entirely +const AUTH_EXCEPT_ROUTES = [ + ['/api/users', 'POST'], + ['/api/external/esign/webhook', "*"] +]; +// Routes that need auth but not a user +const AUTH_WITHOUT_USER_ROUTES = []; +// Routes that need auth and a user, but not a workspace +const AUTH_WITHOUT_WORKSPACE_ROUTES = []; +function isExceptedRoute(route_list, path, method) { + return route_list.some(([routePath, routeMethod]) => { + const pathMatch = routePath.endsWith('/') ? + path.startsWith(routePath) : path === routePath; + return pathMatch && (routeMethod === '*' || routeMethod === method); + }); +} +export default async function authMiddleware(c, next) { + // Skip all checks for the test endpoint + if (c.req.path === '/api/external/test') { + if (DEBUG) { + console.debug("Middleware debug - inserting test variable into request context"); + } + c.set("test", true); + return next(); + } + // Skip ALL API checks for select routes + if (isExceptedRoute(AUTH_EXCEPT_ROUTES, c.req.path, c.req.method)) { + return next(); + } + // Extract API key from headers & verify that it exists + const apiKey = c.req.header("x-api-key"); + if (DEBUG) { + console.debug("Middleware debug - API key header:", apiKey); + } + if (!apiKey) { + return c.json({ ok: false, message: "API key is required" }, 400); + } + // Verify API key + const apiKeyVerified = await verifyApiKey(apiKey); + if (DEBUG) { + console.debug("Middleware debug - API key was verified:", apiKeyVerified); + } + if (!apiKeyVerified) { + return c.json({ ok: false, message: "Invalid API key" }, 401); + } + // Paths that require an API key but not a user + if (isExceptedRoute(AUTH_WITHOUT_USER_ROUTES, c.req.path, c.req.method)) { + return next(); + } + // User look-up + const res = await prisma(c.env).userApiKey.findUnique({ + where: { secret: apiKey }, + include: { user: true } + }); + if (!res) { + return c.json({ ok: false, message: "API key did not exist" }, 401); + } + const user = res.user; + if (DEBUG) { + console.debug("Middleware debug - user:", user.id, user.email, user.workspaceId); + } + if (!user) { + return c.json({ ok: false, message: "User did not exist" }, 401); + } + // Insert the user object into request context + c.set("user", user); + // Paths that require a user but not a workspace + if (isExceptedRoute(AUTH_WITHOUT_WORKSPACE_ROUTES, c.req.path, c.req.method)) { + return next(); + } + // Require the user to have a workspace before giving access + const workspaceId = user.workspaceId; + if (workspaceId == null || workspaceId == undefined) { + return c.json({ ok: false, message: "You must belong to a workspace in order to access this endpoint." }, 400); + } + // Proceed to the route handler + return next(); +} diff --git a/apps/api/src/routes/external/buildings.js b/apps/api/src/routes/external/buildings.js new file mode 100644 index 0000000..9e1808c --- /dev/null +++ b/apps/api/src/routes/external/buildings.js @@ -0,0 +1,78 @@ +import { honoFactory } from "../../lib/hono"; +import { createBuilding, editWorkspaceBuilding, getAllBuildingsByProperty, getAllWorkspaceBuildings, getWorkspaceBuildingById, } from "../../models/buildings"; +const app = honoFactory(); +app.post("/", async (c) => { + const user = c.get("user"); + const body = await c.req.json(); + let propertyId, buildingData; + try { + ; + ({ propertyId, ...buildingData } = body); + } + catch (error) { + console.error(error); + return c.json({ + ok: false, + message: "Request body not in valid format or missing required attributes", + }, 400); + } + try { + const building = await createBuilding(user, propertyId, buildingData, c.env); + return c.json({ ok: true, details: building }, 201); + } + catch (error) { + return c.json({ ok: false, message: error }, 500); + } +}); +app.get("/", async (c) => { + const user = c.get("user"); + try { + const buildings = await getAllWorkspaceBuildings(user, c.env); + return c.json({ ok: true, details: buildings }, 200); + } + catch (error) { + return c.json({ ok: false, message: error }, 500); + } +}); +app.get("/property/:id", async (c) => { + const user = c.get("user"); + const id = c.req.param("id"); + try { + const buildings = await getAllBuildingsByProperty(user, id, c.env); + return c.json({ ok: true, details: buildings }, 200); + } + catch (error) { + return c.json({ ok: false, message: error }, 500); + } +}); +app.get("/:id", async (c) => { + const user = c.get("user"); + const id = c.req.param("id"); + try { + const building = await getWorkspaceBuildingById(user, id, c.env); + return c.json({ ok: true, details: building }, 200); + } + catch (error) { + return c.json({ ok: false, message: error }, 500); + } +}); +app.patch("/:id", async (c) => { + const user = c.get("user"); + const id = c.req.param("id"); + let data; + try { + data = await c.req.json(); + } + catch (error) { + console.error(error); + return c.json({ ok: false, message: error }, 500); + } + try { + const building = await editWorkspaceBuilding(user, id, data, c.env); + return c.json({ ok: true, details: building }, 200); + } + catch (error) { + return c.json({ ok: false, message: error }, 500); + } +}); +export const buildings = app; diff --git a/apps/api/src/routes/external/index.js b/apps/api/src/routes/external/index.js new file mode 100644 index 0000000..63d5ea9 --- /dev/null +++ b/apps/api/src/routes/external/index.js @@ -0,0 +1,15 @@ +import authMiddleware from "./authMiddleware"; +import users from "../../routes/users"; +import { honoFactory } from "../../lib/hono"; +import { properties } from "./properties"; +import { buildings } from "./buildings"; +const external = honoFactory(); +external.use(authMiddleware); +// Routes +external.all('/test', (c) => { + return c.text('GET /api/external/test 200'); +}); +external.route("/users", users); +external.route("/properties", properties); +external.route("/buildings", buildings); +export default external; diff --git a/apps/api/src/routes/external/properties.js b/apps/api/src/routes/external/properties.js new file mode 100644 index 0000000..bfce344 --- /dev/null +++ b/apps/api/src/routes/external/properties.js @@ -0,0 +1,63 @@ +import { honoFactory } from "../../lib/hono"; +import { createProperty, getAllWorkspaceProperties, getWorkspacePropertyById, editWorkspaceProperty } from "../../models/properties"; +const app = honoFactory(); +app.post("/", async (c) => { + const user = c.get("user"); + const body = await c.req.json(); + let type, name; + try { + ({ type, name } = body); + } + catch (error) { + console.error("Invalid or incomplete `body`"); + return c.json({ ok: false, message: "Request body not in valid format or missing required attributes" }, 400); + } + try { + const property = await createProperty(user, name, type, c.env); + return c.json({ ok: true, details: property }, 201); + } + catch (error) { + return c.json({ ok: false, message: error }, 500); + } +}); +app.get("/", async (c) => { + const user = c.get("user"); + try { + const properties = await getAllWorkspaceProperties(user, c.env); + return c.json({ ok: true, details: properties }, 200); + } + catch (error) { + return c.json({ ok: false, message: error }, 500); + } +}); +app.get("/:id", async (c) => { + const user = c.get("user"); + const id = c.req.param('id'); + try { + const property = await getWorkspacePropertyById(user, id, c.env); + return c.json({ ok: true, details: property }, 200); + } + catch (error) { + return c.json({ ok: false, message: error }, 500); + } +}); +app.patch("/:id", async (c) => { + const user = c.get("user"); + const id = c.req.param('id'); + let data; + try { + data = await c.req.json(); + } + catch (error) { + console.error(error); + return c.json({ ok: false, message: error }, 500); + } + try { + const property = await editWorkspaceProperty(user, id, data, c.env); + return c.json({ ok: true, details: property }, 200); + } + catch (error) { + return c.json({ ok: false, message: error }, 500); + } +}); +export const properties = app; diff --git a/apps/api/src/routes/internal/authMiddleware.js b/apps/api/src/routes/internal/authMiddleware.js new file mode 100644 index 0000000..88c1b16 --- /dev/null +++ b/apps/api/src/routes/internal/authMiddleware.js @@ -0,0 +1,36 @@ +import { verifyFrontend } from "../../auth/handler"; +import { prisma } from "../../lib/db"; +const DEBUG = false; // NB! Change to false before committing. +export default async function internalAuthMiddleware(c, next) { + // Skip all checks for the test endpoint + if (c.req.path === '/api/test') { + if (DEBUG) { + console.debug("Middleware debug - inserting test variable into request context"); + } + c.set("test", true); + return next(); + } + const FEKey = c.req.header("x-fe-key"); + if (DEBUG) { + console.debug("Middleware debug - API key header:", FEKey); + } + if (!FEKey) { + return c.json({ ok: false, message: "API key is required" }, 400); + } + const apiKeyVerified = await verifyFrontend(FEKey); + if (!apiKeyVerified) { + return c.json({ ok: false, message: "Invalid API key" }, 401); + } + const userId = c.req.header("x-user-id"); + if (userId) { + const user = await prisma(c.env).user.findUnique({ + where: { + id: userId, + }, + }); + if (user) { + c.set("user", user); + } + } + return next(); +} diff --git a/apps/api/src/routes/internal/index.js b/apps/api/src/routes/internal/index.js new file mode 100644 index 0000000..a19df44 --- /dev/null +++ b/apps/api/src/routes/internal/index.js @@ -0,0 +1,44 @@ +import { createAPIKey } from "../../auth/unkey"; +import { honoFactory } from "../../lib/hono"; +import internalAuthMiddleware from "../../routes/internal/authMiddleware"; +import { POInternalApp } from "./oauth/poweroffice"; +import { poweroffice } from "./poweroffice"; // Import the new route +const internal = honoFactory(); +internal.use(internalAuthMiddleware); +// Routes +internal.all("/test", (c) => { + return c.text("GET /api/internal/test"); +}); +// OAuth +internal.route("/oauth/poweroffice", POInternalApp); +// PowerOffice +internal.route("/poweroffice", poweroffice); +// API key management +internal.post("/workspace/api/create", async (c) => { + let workspaceId; + let serviceName; + let prefix; + try { + const body = await c.req.json(); + workspaceId = body.workspace; + serviceName = body.serviceName ? body.serviceName : ""; + prefix = body.prefix ? body.prefix : ""; + if (!workspaceId) { + return c.json({ ok: false, message: "Supply an ID for the workspace" }, 400); + } + } + catch (error) { + console.error(error); + } + try { + const res = await createAPIKey(workspaceId, serviceName, prefix); + if (res) { + return c.json({ ok: true, message: res }, 201); + } + } + catch (error) { + console.error(error); + } + return c.json({ ok: false, message: "Something went wrong" }, 500); +}); +export default internal; diff --git a/apps/api/src/routes/internal/oauth/poweroffice/index.js b/apps/api/src/routes/internal/oauth/poweroffice/index.js new file mode 100644 index 0000000..b688814 --- /dev/null +++ b/apps/api/src/routes/internal/oauth/poweroffice/index.js @@ -0,0 +1,92 @@ +import { saveAPIKey } from "@/models/workspace"; +import { prisma } from "@/lib/db"; +import { honoFactory } from "@/lib/hono"; +import { exchangeCodeForKey, getAccessToken, getAuthHeaders, getOnboardingBody, getOnboardingHeaders, PO_ONBOARDING_START, } from "@/lib/poweroffice/auth"; +const app = honoFactory(); +app.get("/onboarding-start", async (c) => { + const headers = getOnboardingHeaders(c.env); + const body = getOnboardingBody(c.env); + const url = PO_ONBOARDING_START; + try { + const response = await fetch(url, { + method: "POST", + headers: headers, + body: JSON.stringify(body), + }); + if (response.ok) { + const responseData = await response.json(); + const temporaryUrl = responseData["TemporaryUrl"]; + return c.json({ ok: true, message: temporaryUrl }, 200); + } + else { + console.error(`Error: ${response.statusText}`); + return c.json({ + ok: false, + message: `Failed to start onboarding`, + error: response.statusText, + }, { status: response.status }); + } + } + catch (error) { + console.error(`Network error: ${error.message}`); + return c.json({ error: `Network error: ${error.message}` }, 500); + } +}); +// TODO: remove +app.get("/callback-test", async (c) => { + const { success, token } = c.req.query(); + return c.json({ ok: true, success: success, token: token }, 200); +}); +app.post("/onboarding-finalize", async (c) => { + const body = await c.req.json(); + const db = prisma(c.env); + // Get request.body params + let workspaceId, token, serviceName; + try { + ; + ({ workspaceId, token, serviceName } = body); + } + catch (error) { + console.error("Invalid or incomplete `body`"); + return c.json({ + ok: false, + message: "Request body not in valid format or missing required attributes", + }, 400); + } + // Exchange the onboarding code for client's key + let clientKey; + try { + clientKey = await exchangeCodeForKey(c.env, token); + // Save key to db + await saveAPIKey(db, workspaceId, clientKey, serviceName); + } + catch (error) { + console.error(`Error: ${error}`); + return c.json({ ok: false, error: error }, 500); + } + return c.json({ ok: true }, 200); +}); +app.get("/token-test", async (c) => { + const user = c.get("user"); + if (!user) { + return c.json({ ok: false, message: "x-user-id header was not supplied" }, 400); + } + let token; + try { + token = await getAccessToken(c.env, user.workspaceId); + } + catch (error) { + console.error(error); + return c.json({ ok: false, error: error }, 500); + } + return c.json({ ok: true, user: user, message: token }, 200); +}); +app.get("/dev", async (c) => { + const user = c.get("user"); + if (!user) { + return c.json({ ok: false, message: "x-user-id header was not supplied" }, 400); + } + const headers = getAuthHeaders(c.env, user.workspaceId); + return c.json({ ok: true, user: user, message: headers }, 200); +}); +export const POInternalApp = app; diff --git a/apps/api/src/routes/internal/poweroffice/index.js b/apps/api/src/routes/internal/poweroffice/index.js new file mode 100644 index 0000000..3e91ca6 --- /dev/null +++ b/apps/api/src/routes/internal/poweroffice/index.js @@ -0,0 +1,82 @@ +import { honoFactory } from "@/lib/hono"; +import { getCustomers, getCustomer } from "@/lib/poweroffice/customers"; +import { getProducts, getProduct } from "@/lib/poweroffice/products"; +import { createSalesOrder } from "@/lib/poweroffice/invoice"; +const app = honoFactory(); +// Endpoint to get all customers +app.get("/customers", async (c) => { + const user = c.get("user"); + if (!user) { + return c.json({ ok: false, message: "x-user-id header is missing" }, 400); + } + try { + const customerResponse = await getCustomers(c.env, user.workspaceId); + return c.json({ ok: true, message: customerResponse }, 200); + } + catch (error) { + return c.json({ "ok": false, message: "Network error while fetching customers", "error": error.message }, 500); + } +}); +// Endpoint to get a customer by ID +app.get("/customers/:id", async (c) => { + const user = c.get("user"); + if (!user) { + return c.json({ ok: false, message: "x-user-id header is missing" }, 400); + } + const id = c.req.param("id"); + try { + const customerResponse = await getCustomer(c.env, user.workspaceId, id); + return c.json({ ok: true, message: customerResponse }, 200); + } + catch (error) { + return c.json({ "ok": false, message: "Network error while fetching the customer", "error": error.message }, 500); + } +}); +// Endpoint to get all products +app.get("/products", async (c) => { + const user = c.get("user"); + if (!user) { + return c.json({ ok: false, message: "x-user-id header is missing" }, 400); + } + try { + const productResponse = await getProducts(c.env, user.workspaceId); + return c.json({ ok: true, message: productResponse }, 200); + } + catch (error) { + return c.json({ "ok": false, message: "Network error while fetching products", "error": error.message }, 500); + } +}); +// Endpoint to get a customer by ID +app.get("/products/:id", async (c) => { + const user = c.get("user"); + if (!user) { + return c.json({ ok: false, message: "x-user-id header is missing" }, 400); + } + const id = c.req.param("id"); + try { + const productResponse = await getProduct(c.env, user.workspaceId, id); + return c.json({ ok: true, message: productResponse }, 200); + } + catch (error) { + return c.json({ "ok": false, message: "Network error while fetching the product", "error": error.message }, 500); + } +}); +// Endpoint to create a supplier invoice +app.post("/invoices/create", async (c) => { + const user = c.get("user"); + if (!user) { + return c.json({ ok: false, message: "x-user-id header is missing" }, 400); + } + //const invoiceData = await c.req.json() + // Dummy data + const customerId = 17763838; + const productId = 20681521; + try { + const invoiceResponse = await createSalesOrder(c.env, user.workspaceId, customerId, productId); + return c.json({ ok: true, message: invoiceResponse }, 200); + } + catch (error) { + return c.json({ "ok": false, message: "Network error while creating the invoice", "error": error.message }, 500); + } +}); +export const poweroffice = app; diff --git a/apps/api/src/routes/internal/tripletex/index.js b/apps/api/src/routes/internal/tripletex/index.js new file mode 100644 index 0000000..9535c39 --- /dev/null +++ b/apps/api/src/routes/internal/tripletex/index.js @@ -0,0 +1,211 @@ +import { honoFactory } from "@/lib/hono"; +const app = honoFactory(); +let sessionToken = null; +/** + * Developer Note: + * + * This function creates a session token for the Tripletex API. + * + * Important points to consider: + * - The consumerToken and employeeToken are necessary and should be valid. + * - The expirationDate must be provided in the format YYYY-MM-DD. + * - The request is made using a PUT method with the tokens and expiration date passed as a JSON body. + * - The response should contain the session token which is used for subsequent API requests. + */ +// Endpoint to create a session token for Tripletex API +app.post("/create-session-token", async (c) => { + const env = c.env; + const consumerToken = "eyJ0b2tlbklkIjozNTQ1LCJ0b2tlbiI6InRlc3QtNWY2MjE0YmEtZDc5Zi00YzgyLWJlYzktMGRkZDhiOWRiYjU1In0="; + const employeeToken = "eyJ0b2tlbklkIjo1NzQxLCJ0b2tlbiI6InRlc3QtZGNkN2JhYzktZjAxYi00OTc1LTlhNGYtZTcwNGM0OGQzMWQ2In0="; + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + 1); // Set expiration date to one day in the future + const formattedExpirationDate = expirationDate.toISOString().split("T")[0]; // Format date as YYYY-MM-DD + const url = `https://api.tripletex.io/v2/token/session/:create?consumerToken=${consumerToken}&employeeToken=${employeeToken}&expirationDate=${formattedExpirationDate}`; + try { + const response = await fetch(url, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + }); + const responseText = await response.text(); + console.log("Response from session token endpoint:", responseText); + if (response.ok) { + const data = JSON.parse(responseText); + sessionToken = data.value.token; // Store the session token in the variable + console.log("Session token created:", data); + return c.json(data, 200); + } + else { + console.error(`Error: ${response.statusText}, ${responseText}`); + return c.json({ + message: `Failed to create session token`, + error: `${response.statusText}, ${responseText}`, + }, { status: response.status }); + } + } + catch (error) { + console.error(`Network error: ${error.message}`); + return c.json({ error: `Network error: ${error.message}` }, 500); + } +}); +/** + * Endpoint to get all customers from Tripletex API. + * + * This endpoint uses the stored session token. + */ +app.get("/customer", async (c) => { + if (!sessionToken) { + return c.json({ error: "Session token is not available" }, 500); + } + try { + const headers = { + Authorization: `Basic ${btoa(`0:${sessionToken}`)}`, + "Content-Type": "application/json", + "If-None-Match": "*", // Ensure we get the full response + }; + const url = `https://api.tripletex.io/v2/customer`; + console.log("Fetching customers with URL:", url); + console.log("Fetching customers with headers:", headers); + const response = await fetch(url, { + method: "GET", + headers: headers, + }); + const customersResponseText = await response.text(); + console.log("Response from customers endpoint:", customersResponseText); + if (response.ok) { + const customers = JSON.parse(customersResponseText); + return c.json(customers, 200); + } + else { + console.error(`Error: ${response.statusText}, ${customersResponseText}`); + return c.json({ + message: `Failed to fetch customers`, + error: `${response.statusText}, ${customersResponseText}`, + }, { status: response.status }); + } + } + catch (error) { + console.error(`Network error: ${error.message}`); + return c.json({ error: `Network error: ${error.message}` }, 500); + } +}); +/** + * Endpoint to get all products from Tripletex API. + * + * This endpoint uses the stored session token. + */ +app.get("/product", async (c) => { + if (!sessionToken) { + return c.json({ error: "Session token is not available" }, 500); + } + try { + const headers = { + Authorization: `Basic ${btoa(`0:${sessionToken}`)}`, + "Content-Type": "application/json", + "If-None-Match": "*", // Ensure we get the full response + }; + const url = `https://api.tripletex.io/v2/product`; + console.log("Fetching products with URL:", url); + console.log("Fetching products with headers:", headers); + const response = await fetch(url, { + method: "GET", + headers: headers, + }); + const productsResponseText = await response.text(); + console.log("Response from products endpoint:", productsResponseText); + if (response.ok) { + const products = JSON.parse(productsResponseText); + return c.json(products, 200); + } + else { + console.error(`Error: ${response.statusText}, ${productsResponseText}`); + return c.json({ + message: `Failed to fetch products`, + error: `${response.statusText}, ${productsResponseText}`, + }, { status: response.status }); + } + } + catch (error) { + console.error(`Network error: ${error.message}`); + return c.json({ error: `Network error: ${error.message}` }, 500); + } +}); +/** + * Endpoint to create an invoice in Tripletex API. + * + * This endpoint uses the stored session token. + * + * Developer Note: + * To create an invoice, you need to gather product details such as id, priceExcludingVat, and priceIncludingVat from the /product endpoint. + * + * Example Payload for Testing: + * { + * "invoiceDate": "2024-07-19", + * "invoiceDueDate": "2024-08-19", + * "comment": "Invoice for services rendered", + * "orders": [ + * { + * "customer": { + * "id": 12345, + * "name": "Customer Name" + * }, + * "orderDate": "2024-07-19", + * "deliveryDate": "2024-07-20", + * "orderLines": [ + * { + * "product": { + * "id": 21691004 + * }, + * "description": "Testprodukt for API", + * "count": 1, + * "priceExcludingVat": 100, + * "priceIncludingVat": 125, + * "vatType": { + * "id": 3 + * } + * } + * ] + * } + * ] + * } + */ +app.post("/invoice", async (c) => { + if (!sessionToken) { + return c.json({ error: "Session token is not available" }, 500); + } + const invoiceData = await c.req.json(); + try { + const headers = { + Authorization: `Basic ${btoa(`0:${sessionToken}`)}`, + "Content-Type": "application/json", + }; + const url = `https://api.tripletex.io/v2/invoice`; + console.log("Creating invoice with URL:", url); + console.log("Creating invoice with headers:", headers); + console.log("Creating invoice with data:", JSON.stringify(invoiceData)); + const response = await fetch(url, { + method: "POST", + headers: headers, + body: JSON.stringify(invoiceData), + }); + const invoiceResponseText = await response.text(); + console.log("Response from invoice endpoint:", invoiceResponseText); + if (response.ok) { + const invoice = JSON.parse(invoiceResponseText); + return c.json(invoice, 201); + } + else { + console.error(`Error: ${response.statusText}, ${invoiceResponseText}`); + return c.json({ + message: `Failed to create invoice`, + error: `${response.statusText}, ${invoiceResponseText}`, + }, { status: response.status }); + } + } + catch (error) { + console.error(`Network error: ${error.message}`); + return c.json({ error: `Network error: ${error.message}` }, 500); + } +}); +export const tripletex = app; diff --git a/apps/api/src/routes/projects.js b/apps/api/src/routes/projects.js new file mode 100644 index 0000000..13688a1 --- /dev/null +++ b/apps/api/src/routes/projects.js @@ -0,0 +1,69 @@ +import { Hono } from "hono"; +import { prisma } from "../lib/db"; +const projects = new Hono(); +projects.post("/", async (c) => { + const apiKey = c.req.header("x-api-key"); + if (!apiKey) { + return c.json({ ok: false, message: "API key is required" }, 401); + } + const user = await prisma(c.env).user.findUnique({ + where: { apiKey }, + }); + if (!user) { + return c.json({ ok: false, message: "Invalid API key" }, 401); + } + // Parse and validate the request body + let result; + try { + result = await c.req.json(); + } + catch (error) { + return c.json({ ok: false, message: "Invalid JSON body" }, 400); + } + if (!result || typeof result.name !== "string") { + return c.json({ + ok: false, + message: "Invalid project data", + errors: ["'name' is required and should be a string"], + }, 400); + } + const { name } = result; + const projectExists = await prisma(c.env).project.findFirst({ + where: { + name, + userId: user.id, + }, + }); + if (projectExists) { + return c.json({ ok: false, message: "Project with this name already exists" }, 409); + } + const project = await prisma(c.env).project.create({ + data: { + name, + userId: user.id, + }, + }); + return c.json({ ok: true, message: "Project created", project }); +}); +projects.get("/", async (c) => { + const apiKey = c.req.header("x-api-key"); + if (!apiKey) { + return c.json({ ok: false, message: "API key is required" }, 401); + } + const user = await prisma(c.env).user.findUnique({ + where: { apiKey }, + }); + if (!user) { + return c.json({ ok: false, message: "Invalid API key" }, 401); + } + const projects = await prisma(c.env).project.findMany({ + where: { + userId: user.id, + }, + }); + if (!projects || projects.length === 0) { + return c.json({ ok: false, message: "No projects found" }, 404); + } + return c.json({ ok: true, projects }); +}); +export default projects; diff --git a/apps/api/src/routes/users.js b/apps/api/src/routes/users.js new file mode 100644 index 0000000..ec75be6 --- /dev/null +++ b/apps/api/src/routes/users.js @@ -0,0 +1,58 @@ +// users.ts +import { Hono } from "hono"; +import { prisma } from "../lib/db"; +import { generateApiKey } from "../lib/generateApiKey"; +import { parsePrismaError } from "../lib/parsePrismaError"; +import { UserSchema } from "../zod"; +const users = new Hono(); +// POST - Create User +users.post("/", async (c) => { + const inputData = UserSchema.omit({ + id: true, + events: true, + }).parse(await c.req.json()); + const { email, name, plan } = inputData; + if (!email) { + return c.json({ ok: false, message: "Missing required field: email" }, 400); + } + const apiKey = generateApiKey(); + try { + const user = await prisma(c.env).user.upsert({ + where: { email }, + update: { name, plan }, + create: { email, name: name || "", plan: plan || "", apiKey }, + }); + return c.json({ ok: true, message: "User created", user }); + } + catch (error) { + return c.json({ + ok: false, + message: "Failed to create or update user", + error: parsePrismaError(error), + }, 500); + } +}); +// GET - Retrieve user with specific API key +users.get("/", async (c) => { + const apiKey = c.req.header("x-api-key"); + if (!apiKey) { + return c.json({ ok: false, message: "API key is required" }, 401); + } + try { + const user = await prisma(c.env).user.findUnique({ + where: { apiKey }, + }); + if (!user) { + return c.json({ ok: false, message: "Invalid API key" }, 401); + } + return c.json({ ok: true, user }); + } + catch (error) { + return c.json({ + ok: false, + message: "Failed to retrieve user", + error: error.message || "Unknown error", + }, 500); + } +}); +export default users; diff --git a/apps/api/src/types.js b/apps/api/src/types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/apps/api/src/types.js @@ -0,0 +1 @@ +export {}; diff --git a/apps/api/src/validators/index.js b/apps/api/src/validators/index.js new file mode 100644 index 0000000..81b3f03 --- /dev/null +++ b/apps/api/src/validators/index.js @@ -0,0 +1,125 @@ +import { OpenAPIHono, z } from "@hono/zod-openapi"; +import * as yaml from "yaml"; +import { createDocument, extendZodWithOpenApi, } from "zod-openapi"; +extendZodWithOpenApi(z); +const app = new OpenAPIHono(); +const registry = app.openAPIRegistry; +// Define the Event Schema with detailed OpenAPI extensions +export const EventSchema = z + .object({ + name: z.string().openapi({ + description: "The name of the event.", + example: "Annual Meetup", + }), + channel: z.string().openapi({ + description: "The channel name associated with the event.", + example: "Main Channel", + }), + userId: z.string().openapi({ + description: "Associated ID that you want to have on the user", + example: "user999 OR John Doe", + }), + icon: z.string().optional().openapi({ + description: "An optional icon for visual representation of the event.", + example: "icon_event.png", + }), + notify: z.boolean().openapi({ + description: "Flag indicating whether users should be notified about the event.", + example: true, + }), + tags: z + .record(z.string()) + .optional() + .openapi({ + description: "Tags providing additional context or categorization for the event.", + example: { Networking: "Yes", Tech: "Yes" }, + }), +}) + .openapi({ ref: "Event" }); +// Schema for creating an event +export const EventCreateSchema = z + .object({ + name: z.string().openapi({ + description: "The name of the event.", + example: "You got a new payment", + }), + channel: z.string().openapi({ + description: "The channel name where the event is registered.", + example: "new-channel-name", + }), + userId: z.string().openapi({ + description: "The ID of the user associated with the event.", + example: "user-999", + }), + icon: z.string().optional().openapi({ + description: "An optional icon representing the event.", + example: "🎉", + }), + notify: z.boolean().openapi({ + description: "Whether to notify users about the event.", + example: true, + }), + tags: z + .record(z.string()) + .optional() + .openapi({ + description: "Additional tags providing context for the event.", + example: { plan: "premium", cycle: "monthly" }, + }), +}) + .openapi({ ref: "EventCreate" }); +// CRUD operations for Events +export const logEvent = { + operationId: "logEvent", + summary: "Log a new event", + description: "Logs a new event for a user in a specified channel.", + requestBody: { + description: "Details of the event to log.", + content: { + "application/json": { + schema: EventCreateSchema, + }, + }, + }, + responses: { + "201": { + description: "Event logged successfully.", + content: { + "application/json": { + schema: EventSchema, + }, + }, + }, + "404": { + description: "Channel or project not found.", + }, + "400": { + description: "Invalid input data.", + }, + }, +}; +// Generate an OpenAPI document +const document = createDocument({ + openapi: "3.1.0", + info: { + title: "User and Event Management API", + description: "API for managing users, their events, and projects.", + version: "1.0.0", + }, + servers: [ + { + url: "https://example.com", + description: "The production server.", + }, + ], + paths: { + "/events": { post: logEvent }, + }, + components: { + schemas: { + Event: EventSchema, + EventCreate: EventCreateSchema, + }, + }, +}); +console.log(yaml.stringify(document)); diff --git a/apps/api/src/validators/types.js b/apps/api/src/validators/types.js new file mode 100644 index 0000000..ba8741d --- /dev/null +++ b/apps/api/src/validators/types.js @@ -0,0 +1,10 @@ +"use strict"; +// interface User { +// workspaceId: any; +// id: number; +// name?: string; +// email: string; +// plan: string; +// events: Event[]; +// createdAt: Date; +// } diff --git a/apps/api/src/zod/index.js b/apps/api/src/zod/index.js new file mode 100644 index 0000000..701cf8d --- /dev/null +++ b/apps/api/src/zod/index.js @@ -0,0 +1,70 @@ +import { z } from "zod"; +import { extendZodWithOpenApi } from "zod-openapi"; +// Extend Zod with OpenAPI functionality +extendZodWithOpenApi(z); +// Define the Event Schema with OpenAPI extensions +export const EventSchema = z.object({ + id: z.number().openapi({ + description: "The unique identifier of the event.", + example: 101, + }), + name: z.string().optional().openapi({ + description: "The user's name.", + example: "John Doe", + }), + channel: z.string().openapi({ + description: "The channel name associated with the event.", + example: "Main Channel", + }), + event: z.string().openapi({ + description: "The name of the event.", + example: "Annual Meetup", + }), + userId: z.number().openapi({ + description: "The ID of the user who created the event.", + example: 501, + }), + icon: z.string().optional().openapi({ + description: "An optional icon for the event.", + example: "icon.png", + }), + notify: z.boolean().openapi({ + description: "Flag to notify users about the event.", + example: true, + }), + tags: z + .record(z.string()) + .optional() + .openapi({ + description: "Optional tags for additional event metadata.", + example: { tag1: "Networking", tag2: "Tech" }, + }), + createdAt: z.date().openapi({ + description: "The creation date of the event.", + }), +}); +// Define the User Schema with OpenAPI extensions +export const UserSchema = z.object({ + id: z.number().openapi({ + description: "The unique identifier of the user.", + example: 501, + }), + name: z.string().optional().openapi({ + description: "The user's name.", + example: "John Doe", + }), + email: z.string().email().openapi({ + description: "The user's email address.", + example: "john.doe@example.com", + }), + plan: z.string().openapi({ + description: "The subscription plan of the user.", + example: "Pro", + }), + events: z.array(EventSchema).openapi({ + description: "A list of events associated with the user.", + }), + createdAt: z.date().openapi({ + description: "The creation date of the user's account.", + }), +}); diff --git a/apps/www/.content-collections/cache/mapping.json b/apps/www/.content-collections/cache/mapping.json index 8c41fe4..5bd2a12 100644 --- a/apps/www/.content-collections/cache/mapping.json +++ b/apps/www/.content-collections/cache/mapping.json @@ -1 +1 @@ -{"BlogPost":{"introducing-dub":["fd23d9f3799a53869ccfe0d755d67958e3cb5b9e98d318f2de57a8f0563b0196"],"migration-assistants":["af4e5f89fb972d10b1486612c93c8221f5aa9a370f7f586c3c45a3f34ffb3f60"],"rebrand":["2285bad189a0c9eabb824fdb6b46c25fe8d82197df0dd7001fc6cb7c54e356e6"],"introducing-dub copy":["0543e29169a461c4e8d8f2f7ca99b8dc4a641bfebcf1528e81f2fdd1af4eff2a"],"introduserer-propdock":["aca5356df85fb2fa659f0af3606f014e5a40ddbc9f227ac9b72a2c6c4e20a2fa"],"hjelpesenter":["6bf2436d9ffa12a0c608af44803d246dec984352076cd2d9e5e11936780d1a3a"]},"ChangelogPost":{"all-time-link-stats":["08d089b28606b786b33bbafaaf868e62b5631bad61985025b19ea420e05a5628"],"custom-qr-codes":["68885629052829cface92631ae3522c2312aaa37d3309290081fe3c6411ab6e9"],"custom-social-cards":["92b094efbd95cefb1619547f1afb589d0607c491d20f7d2dfe5f4d54ad14e6d0"],"detailed-link-stats":["7a14f5b5ce54b7e7fce9cbc9b0fafa2ae08127fccbdbc015cc3d854653acab47"],"duplicating-links":["02d53f5ffae671e0a14a83c450351c4e44cd36767fef072ad6792660a82e9d15"],"geo-targeting":["5e69134ab749f16e15242ce162b6d10c82b02c153adf1209c3bd56b32ebfaf45"],"improved-link-cards":["45ae18686bfc684f94ec884face92485b9ca150dde179a482f83e5a7262c3d89"],"improved-link-management":["55610f77044281c29927c1300c1f0bdc69ef28ff2548b90c5d3d2fbccea6aa92"],"improved-pricing":["f3a56dcfb193c0572a7d5f2698d5b396f60ad1a1422d86a27b1f36c60a3c5f4a"],"introducing-tags":["2a724bb9a2ec768c8d4b67bd45f49d978d8a9e83d1cc03d1b5ee0db72d66008d"],"keyboard-shortcuts":["7881b1348e9946c8088bc20f09585d9e5e1ba153878180eb306c97b2a0bbb588"],"link-cloaking":["04bcf35c6d8b8aec5051609eddf90a7a6b1534c4e8c34c9efb9246d3884f8100"],"link-comments":["590ea828e03006d4bc0cb8b7e636aea73aa73f6d460135d23faf7a751e2b5804"],"link-inspector":["65769346cf71e8e400530201056e470bbba1bb97b6e08d59690b5db18cb4a9be"],"link-search":["063dd1e8fdd0429a31d4a1c07409244fc529d185bfeeecba7175a0768a6add14"],"monthly-summaries":["dd7ed0ab9f76a95affeae6db49e3e9c381ce47f8c26826d5fc64584ef5af1304"],"new-tag-creation-flow":["76e2c76332a8232ce2d79f91e610c9102fa0bee90eebb4805a28f4988a01620d"],"pagination":["ba35be4df3873178eb42fd976c6f0b0c967f45ab2438aec7d497a3b4d2072acf"],"pass-url-parameters":["09caa4ebb632b2226a973240e41d21681c48df7d4f09de5f22ad2ff6a78ae65f"],"project-level-billing":["15f479c66d3b5c4c2982c4af76202195bda8e935c05c363d3d80d2f501f56e90"],"public-stats-pages":["416e42c95807cc964fb060bfdf7048e15d8861433f7f90643327d0a9c5c00cb3"],"sign-in-with-google":["7c132dd8c45eeec0e2a155a58f608fb98821069edc9e118921211db4a45fe0f1"],"sort-last-clicked":["827d81454bb37ad10710d642e7784da0b92b5dadc3a347450a8ea69bbc5f1409"],"team-invites":["4624d8693dbd1f6ecdecb0f74029979c9efbe2abd0270d14335cc71a7e911702"],"tinybird-migration":["3a1bb5f559b218260cbd59d18b3851fe01ca15617314bb3348539d6d6043135f"],"unlimited-domains":["34560ba4091c7f6837fdb900a89db28f09e4667f0dd0f972d49f0e197f92e964"],"tastatursnarveier":["4c8e7f7eb1255cfbc3e2fa84aea878cba9db8a17c6400f72e66f162c22a7d2a2"]},"CustomersPost":{"prisma":["c01e43a4672dea3330a81fde89d1e5452981e0955290af41d974a274ee84df22"],"corponor":["134265581dc92538aa7ea928058e9da846f98ff582bbed8c024dbfcf154fea5d"]},"HelpPost":{"azure-saml":["41297d889bbf16ccff0caf89f46ee84b288f38500caecbd5c45c4b1b739de6b3"],"azure-scim":["0f5751a5bdb4e547770bb2c3fdac46ece93827ed0532fac6fcdaaa287297dfa0"],"custom-qr-codes":["055ca01799357a49e9b45c998fed0d4a9670f7ea930331636261401e5fba22a6"],"dub-api":["90cf12eba13f444707f3ddc8cc6e0f5069530b3c953779b6a5f8ccf761f66591"],"google-saml":["b686b2de50edb3631da7ab95aeb5e0b05ecafc0649364acd2495334820c8e5e6"],"how-to-add-custom-domain":["63381cc2841d54f799f91c7ca25c60ee6947aa6fd9c8fde8ebd73f0dbf9955bd"],"how-to-check-link-destination":["0b52f66198a3371ee43f3a2d4dd926f93b1d847258941a656414605eb09a42d5"],"how-to-create-link":["91da656b992313ae2ab87398449ceacfa515c5e5989e19da6b48178308252f0c"],"how-to-download-invoice":["bd29baecbb4b27720f1ddf67809779f3f9caac0885e03f72dd2c7c34c3dbeae1"],"how-to-invite-teammates":["91b71dfe181b8c91c100c9ec55edf22268a6bfd3506a3e4627551dbc7bdc0b88"],"how-to-redirect-root-domain":["89c6f6fefa2b1ea70d17b148fdef6a11edad494c2e1fd62946be2a0f4aa467c6"],"how-to-use-tags":["52918fa46737f15b9bfc15ed9eb279c3721d20da0875001d5c4d69298d8710ea"],"migrating-from-bitly":["88549e8605cd4849d5ae4ce24513cb03bcfe6f5e1f88741cfa439b3ed6a59e0f"],"migrating-from-short":["bedd577faa925ec2b1a93470e1c50e81b1102dfdb24e704b457e6052c443db66"],"okta-saml":["f27fa6bc6395676f640ec877df06854d275a662028dcc3fcb7a73a2c2faa486b"],"okta-scim":["321bcfc409ecd5d6903faeb8994c0033bb7ae94ec288cc1b5ef200670e8873f2"],"pro-plan":["9f2dad3bc580914aacee6da9739ea647ec69728856bf05d45174d55c502ad319"],"public-stats-pages":["bb166d178a8ad45e9d3f85be7c26c964a5f95ebafe85704e1f3f6eb0e6426c3b"],"using-cloudflare-domains":["2e5fa89532af618f78092ffb4dbdc946a7681c4978a6c68c1c5a023acdc2afe6"],"what-is-a-project":["787b5ee1c33c16e5dd66f379753fcd1e7d50151fe448fd13cbea93567f8bab41"],"what-is-dub":["903dfd9577514942045eb89f9d9d2dd41e48247b2fa02be9ed018936a0d22dff"],"why-are-dub-links-blacklisted":["24c6b3bb297aa807ae9adfbf1a441d6138608bd6dec796b181bdf839be8e272b"],"introduserer-propdock":["be79f959c028159944afee69c83787466bec2dc8d42966fd09eed3fb87c96fde"]},"LegalPost":{"abuse":["3cd1de550777813215278789abebdf5e1cd9c576e8290fd03076a75d68ef9749"],"privacy":["19e1865fbf09c2aa1da8a4a37a0dde8ed50d9e2f2dd54169bbb75bcee3a5c98c"],"terms":["0d028c65039ce816a72d08a8a6440acb507006b18ab05f8616d4bf1ccf785a01"]}} \ No newline at end of file +{"BlogPost":{"introducing-dub":["fd23d9f3799a53869ccfe0d755d67958e3cb5b9e98d318f2de57a8f0563b0196"],"migration-assistants":["af4e5f89fb972d10b1486612c93c8221f5aa9a370f7f586c3c45a3f34ffb3f60"],"rebrand":["2285bad189a0c9eabb824fdb6b46c25fe8d82197df0dd7001fc6cb7c54e356e6"],"introducing-dub copy":["0543e29169a461c4e8d8f2f7ca99b8dc4a641bfebcf1528e81f2fdd1af4eff2a"],"introduserer-propdock":["059969128a569ed67afb20969688aac3256749d14ce8e419b9748641fe353528"],"hjelpesenter":["dd43b43fed46b2ec4286871b47da59dffb87f28b2a96d7107382fc09fe0e3a2a"]},"ChangelogPost":{"all-time-link-stats":["08d089b28606b786b33bbafaaf868e62b5631bad61985025b19ea420e05a5628"],"custom-qr-codes":["68885629052829cface92631ae3522c2312aaa37d3309290081fe3c6411ab6e9"],"custom-social-cards":["92b094efbd95cefb1619547f1afb589d0607c491d20f7d2dfe5f4d54ad14e6d0"],"detailed-link-stats":["7a14f5b5ce54b7e7fce9cbc9b0fafa2ae08127fccbdbc015cc3d854653acab47"],"duplicating-links":["02d53f5ffae671e0a14a83c450351c4e44cd36767fef072ad6792660a82e9d15"],"geo-targeting":["5e69134ab749f16e15242ce162b6d10c82b02c153adf1209c3bd56b32ebfaf45"],"improved-link-cards":["45ae18686bfc684f94ec884face92485b9ca150dde179a482f83e5a7262c3d89"],"improved-link-management":["55610f77044281c29927c1300c1f0bdc69ef28ff2548b90c5d3d2fbccea6aa92"],"improved-pricing":["f3a56dcfb193c0572a7d5f2698d5b396f60ad1a1422d86a27b1f36c60a3c5f4a"],"introducing-tags":["2a724bb9a2ec768c8d4b67bd45f49d978d8a9e83d1cc03d1b5ee0db72d66008d"],"keyboard-shortcuts":["7881b1348e9946c8088bc20f09585d9e5e1ba153878180eb306c97b2a0bbb588"],"link-cloaking":["04bcf35c6d8b8aec5051609eddf90a7a6b1534c4e8c34c9efb9246d3884f8100"],"link-comments":["590ea828e03006d4bc0cb8b7e636aea73aa73f6d460135d23faf7a751e2b5804"],"link-inspector":["65769346cf71e8e400530201056e470bbba1bb97b6e08d59690b5db18cb4a9be"],"link-search":["063dd1e8fdd0429a31d4a1c07409244fc529d185bfeeecba7175a0768a6add14"],"monthly-summaries":["dd7ed0ab9f76a95affeae6db49e3e9c381ce47f8c26826d5fc64584ef5af1304"],"new-tag-creation-flow":["76e2c76332a8232ce2d79f91e610c9102fa0bee90eebb4805a28f4988a01620d"],"pagination":["ba35be4df3873178eb42fd976c6f0b0c967f45ab2438aec7d497a3b4d2072acf"],"pass-url-parameters":["09caa4ebb632b2226a973240e41d21681c48df7d4f09de5f22ad2ff6a78ae65f"],"project-level-billing":["15f479c66d3b5c4c2982c4af76202195bda8e935c05c363d3d80d2f501f56e90"],"public-stats-pages":["416e42c95807cc964fb060bfdf7048e15d8861433f7f90643327d0a9c5c00cb3"],"sign-in-with-google":["7c132dd8c45eeec0e2a155a58f608fb98821069edc9e118921211db4a45fe0f1"],"sort-last-clicked":["827d81454bb37ad10710d642e7784da0b92b5dadc3a347450a8ea69bbc5f1409"],"team-invites":["4624d8693dbd1f6ecdecb0f74029979c9efbe2abd0270d14335cc71a7e911702"],"tinybird-migration":["3a1bb5f559b218260cbd59d18b3851fe01ca15617314bb3348539d6d6043135f"],"unlimited-domains":["34560ba4091c7f6837fdb900a89db28f09e4667f0dd0f972d49f0e197f92e964"],"tastatursnarveier":["5a9d5a88d8ab4a76ea3c415ac6448531f1032f6c8258f112321f86d3fc499941"]},"CustomersPost":{"prisma":["c01e43a4672dea3330a81fde89d1e5452981e0955290af41d974a274ee84df22"],"corponor":["6da8ca01f013ac648c71548759dbb2a7e81c205b845548d7b482891497ff8f0d"]},"HelpPost":{"azure-saml":["41297d889bbf16ccff0caf89f46ee84b288f38500caecbd5c45c4b1b739de6b3"],"azure-scim":["0f5751a5bdb4e547770bb2c3fdac46ece93827ed0532fac6fcdaaa287297dfa0"],"custom-qr-codes":["055ca01799357a49e9b45c998fed0d4a9670f7ea930331636261401e5fba22a6"],"dub-api":["90cf12eba13f444707f3ddc8cc6e0f5069530b3c953779b6a5f8ccf761f66591"],"google-saml":["b686b2de50edb3631da7ab95aeb5e0b05ecafc0649364acd2495334820c8e5e6"],"how-to-add-custom-domain":["63381cc2841d54f799f91c7ca25c60ee6947aa6fd9c8fde8ebd73f0dbf9955bd"],"how-to-check-link-destination":["0b52f66198a3371ee43f3a2d4dd926f93b1d847258941a656414605eb09a42d5"],"how-to-create-link":["91da656b992313ae2ab87398449ceacfa515c5e5989e19da6b48178308252f0c"],"how-to-download-invoice":["bd29baecbb4b27720f1ddf67809779f3f9caac0885e03f72dd2c7c34c3dbeae1"],"how-to-invite-teammates":["91b71dfe181b8c91c100c9ec55edf22268a6bfd3506a3e4627551dbc7bdc0b88"],"how-to-redirect-root-domain":["89c6f6fefa2b1ea70d17b148fdef6a11edad494c2e1fd62946be2a0f4aa467c6"],"how-to-use-tags":["52918fa46737f15b9bfc15ed9eb279c3721d20da0875001d5c4d69298d8710ea"],"migrating-from-bitly":["88549e8605cd4849d5ae4ce24513cb03bcfe6f5e1f88741cfa439b3ed6a59e0f"],"migrating-from-short":["bedd577faa925ec2b1a93470e1c50e81b1102dfdb24e704b457e6052c443db66"],"okta-saml":["f27fa6bc6395676f640ec877df06854d275a662028dcc3fcb7a73a2c2faa486b"],"okta-scim":["321bcfc409ecd5d6903faeb8994c0033bb7ae94ec288cc1b5ef200670e8873f2"],"pro-plan":["9f2dad3bc580914aacee6da9739ea647ec69728856bf05d45174d55c502ad319"],"public-stats-pages":["bb166d178a8ad45e9d3f85be7c26c964a5f95ebafe85704e1f3f6eb0e6426c3b"],"using-cloudflare-domains":["2e5fa89532af618f78092ffb4dbdc946a7681c4978a6c68c1c5a023acdc2afe6"],"what-is-a-project":["787b5ee1c33c16e5dd66f379753fcd1e7d50151fe448fd13cbea93567f8bab41"],"what-is-dub":["903dfd9577514942045eb89f9d9d2dd41e48247b2fa02be9ed018936a0d22dff"],"why-are-dub-links-blacklisted":["24c6b3bb297aa807ae9adfbf1a441d6138608bd6dec796b181bdf839be8e272b"],"introduserer-propdock":["282cbddf61f8d6011dc7c35c26e10b22e9d79307d3b3828a6849174f43fa1f45"]},"LegalPost":{"abuse":["3cd1de550777813215278789abebdf5e1cd9c576e8290fd03076a75d68ef9749"],"privacy":["2445a26f527259c0e190a81f37f2773a292ff9343ac58a8596b3b15e77f1efd5"],"terms":["c9bab0ea0d472b9b68d9953afddafb77bd7ce19323fc1d55c144a8521e52b2c6"]}} \ No newline at end of file diff --git a/apps/www/.content-collections/generated/allBlogPosts.js b/apps/www/.content-collections/generated/allBlogPosts.js index c899a24..93cdabc 100644 --- a/apps/www/.content-collections/generated/allBlogPosts.js +++ b/apps/www/.content-collections/generated/allBlogPosts.js @@ -1,6 +1,6 @@ export default [ { - "content": "Velkommen til Propdock Help Center – din nye kunnskapsbase for alt relatert til Propdock og eiendomsadministrasjon.\n\n## Hvorfor et Help Center?\n\nSiden lanseringen av Propdock har vi mottatt mange spørsmål fra våre brukere via ulike kanaler som e-post, chat og sosiale medier. Vi la merke til at mange av disse spørsmålene gikk igjen. Derfor bestemte vi oss for å bygge et Help Center for å hjelpe våre brukere med å finne svar på sine spørsmål – uten å måtte vente på svar fra oss.\n\n## Hva finner du i Help Center?\n\nPropdock Help Center er en samling av artikler som dekker de vanligste spørsmålene vi mottar fra våre brukere. Det er en levende kunnskapsbase som vi vil oppdatere regelmessig.\n\nHer er noen av de populære artiklene i Help Center:\n\n\n\nVi har også opprettet kategorier for å hjelpe deg med å finne artiklene du leter etter:\n\n1. **Oversikt**: Lær om Propdock-plattformen og hvordan den kan hjelpe deg.\n2. **Kom i gang**: Kom i gang med Propdock med våre omfattende guider.\n3. **Eiendomsadministrasjon**: Lær hvordan du effektivt administrerer dine eiendommer.\n4. **Analyse og rapportering**: Utnytt Propdocks analyseverktøy for å se hvordan dine eiendommer presterer.\n5. **Integrasjoner**: Lær hvordan du integrerer Propdock med andre systemer.\n\n## Intuitiv søkeopplevelse\n\nHelp Center har en brukervennlig søkefunksjon som lar deg finne svar på dine spørsmål raskt og enkelt. Søkefunksjonen er tolerant for skrivefeil og lar deg søke etter artikler basert på tittel og overskrifter.\n\n\n\n## En bedre støtteopplevelse for våre brukere\n\nDette er en etterlengtet funksjon som vil hjelpe oss med å gi bedre støtte til våre brukere. Vi er forpliktet til å kontinuerlig forbedre og utvide vårt Help Center for å møte dine behov.\n\nHvis du har spørsmål eller tilbakemeldinger om Help Center, ikke nøl med å sende oss en e-post eller ta kontakt med oss på sosiale medier.\n\nVi håper at Propdock Help Center vil være en verdifull ressurs for deg i din eiendomsadministrasjon.", + "content": "Velkommen til Propdock Help Center – din nye kunnskapsbase for alt relatert til Propdock og eiendomsadministrasjon.\r\n\r\n## Hvorfor et Help Center?\r\n\r\nSiden lanseringen av Propdock har vi mottatt mange spørsmål fra våre brukere via ulike kanaler som e-post, chat og sosiale medier. Vi la merke til at mange av disse spørsmålene gikk igjen. Derfor bestemte vi oss for å bygge et Help Center for å hjelpe våre brukere med å finne svar på sine spørsmål – uten å måtte vente på svar fra oss.\r\n\r\n## Hva finner du i Help Center?\r\n\r\nPropdock Help Center er en samling av artikler som dekker de vanligste spørsmålene vi mottar fra våre brukere. Det er en levende kunnskapsbase som vi vil oppdatere regelmessig.\r\n\r\nHer er noen av de populære artiklene i Help Center:\r\n\r\n\r\n\r\nVi har også opprettet kategorier for å hjelpe deg med å finne artiklene du leter etter:\r\n\r\n1. **Oversikt**: Lær om Propdock-plattformen og hvordan den kan hjelpe deg.\r\n2. **Kom i gang**: Kom i gang med Propdock med våre omfattende guider.\r\n3. **Eiendomsadministrasjon**: Lær hvordan du effektivt administrerer dine eiendommer.\r\n4. **Analyse og rapportering**: Utnytt Propdocks analyseverktøy for å se hvordan dine eiendommer presterer.\r\n5. **Integrasjoner**: Lær hvordan du integrerer Propdock med andre systemer.\r\n\r\n## Intuitiv søkeopplevelse\r\n\r\nHelp Center har en brukervennlig søkefunksjon som lar deg finne svar på dine spørsmål raskt og enkelt. Søkefunksjonen er tolerant for skrivefeil og lar deg søke etter artikler basert på tittel og overskrifter.\r\n\r\n\r\n\r\n## En bedre støtteopplevelse for våre brukere\r\n\r\nDette er en etterlengtet funksjon som vil hjelpe oss med å gi bedre støtte til våre brukere. Vi er forpliktet til å kontinuerlig forbedre og utvide vårt Help Center for å møte dine behov.\r\n\r\nHvis du har spørsmål eller tilbakemeldinger om Help Center, ikke nøl med å sende oss en e-post eller ta kontakt med oss på sosiale medier.\r\n\r\nVi håper at Propdock Help Center vil være en verdifull ressurs for deg i din eiendomsadministrasjon.", "title": "Propdock hjelpesenter – Din kunnskapsbase for eiendomsadministrasjon", "categories": [ "company" @@ -45,7 +45,7 @@ export default [ "githubRepos": [] }, { - "content": "Velkommen til Propdock – den neste generasjonen innen eiendomsadministrasjon. Vi kombinerer kraftig analyse, brukervennlig grensesnitt og åpen kildekode for å gi deg den ultimate løsningen for eiendomsforvaltning.\n\n## Kraftig analyse for smarte beslutninger\n\nI hjertet av Propdock ligger vår avanserte analysemotor. Vi gir deg innsikt i:\n\n- Belegg og leieinntekter\n- Driftskostnader og vedlikeholdsutgifter\n- Markedstrender og verdivurderinger\n\nMed Propdock får du et 360-graders syn på din eiendomsportefølje, slik at du kan ta informerte beslutninger og maksimere avkastningen.\n\n\n\n## Sømløse integrasjoner\n\nPropdock er designet for å passe sømløst inn i ditt eksisterende økosystem. Vi tilbyr integrasjoner med:\n\n- Regnskapssystemer\n- CRM-verktøy\n- Vedlikeholdsplanleggere\n- Og mye mer!\n\nVår fleksible arkitektur sikrer at Propdock vokser med dine behov.\n\n## Åpen kildekode for maksimal fleksibilitet\n\nVi tror på transparens og fellesskap. Propdock er fullstendig åpen kildekode, noe som betyr at du kan:\n\n- Tilpasse plattformen til dine spesifikke behov\n- Bidra til utviklingen og forme fremtiden til Propdock\n- Ha full kontroll over dine data og prosesser\n\nBesøk vårt [GitHub-repository](https://github.com/propdock/propdock) for å utforske koden og bli en del av fellesskapet.\n\n## Start din Propdock-reise i dag\n\nEnten du forvalter en enkelt eiendom eller en omfattende portefølje, er Propdock skapt for å forenkle din hverdag og forbedre dine resultater.\n\n[Kom i gang gratis](https://propdock.no/) og opplev fremtiden innen eiendomsadministrasjon.", + "content": "Velkommen til Propdock – den neste generasjonen innen eiendomsadministrasjon. Vi kombinerer kraftig analyse, brukervennlig grensesnitt og åpen kildekode for å gi deg den ultimate løsningen for eiendomsforvaltning.\r\n\r\n## Kraftig analyse for smarte beslutninger\r\n\r\nI hjertet av Propdock ligger vår avanserte analysemotor. Vi gir deg innsikt i:\r\n\r\n- Belegg og leieinntekter\r\n- Driftskostnader og vedlikeholdsutgifter\r\n- Markedstrender og verdivurderinger\r\n\r\nMed Propdock får du et 360-graders syn på din eiendomsportefølje, slik at du kan ta informerte beslutninger og maksimere avkastningen.\r\n\r\n\r\n\r\n## Sømløse integrasjoner\r\n\r\nPropdock er designet for å passe sømløst inn i ditt eksisterende økosystem. Vi tilbyr integrasjoner med:\r\n\r\n- Regnskapssystemer\r\n- CRM-verktøy\r\n- Vedlikeholdsplanleggere\r\n- Og mye mer!\r\n\r\nVår fleksible arkitektur sikrer at Propdock vokser med dine behov.\r\n\r\n## Åpen kildekode for maksimal fleksibilitet\r\n\r\nVi tror på transparens og fellesskap. Propdock er fullstendig åpen kildekode, noe som betyr at du kan:\r\n\r\n- Tilpasse plattformen til dine spesifikke behov\r\n- Bidra til utviklingen og forme fremtiden til Propdock\r\n- Ha full kontroll over dine data og prosesser\r\n\r\nBesøk vårt [GitHub-repository](https://github.com/propdock/propdock) for å utforske koden og bli en del av fellesskapet.\r\n\r\n## Start din Propdock-reise i dag\r\n\r\nEnten du forvalter en enkelt eiendom eller en omfattende portefølje, er Propdock skapt for å forenkle din hverdag og forbedre dine resultater.\r\n\r\n[Kom i gang gratis](https://propdock.no/) og opplev fremtiden innen eiendomsadministrasjon.", "title": "Introduserer Propdock – Revolusjonerende eiendomsadministrasjon", "categories": [ "company" diff --git a/apps/www/.content-collections/generated/allChangelogPosts.js b/apps/www/.content-collections/generated/allChangelogPosts.js index f7e4091..8e729f5 100644 --- a/apps/www/.content-collections/generated/allChangelogPosts.js +++ b/apps/www/.content-collections/generated/allChangelogPosts.js @@ -1,6 +1,6 @@ export default [ { - "content": "Vi introduserer tastatursnarveier på Propdock. Med snarveier kan du effektivisere arbeidsflyten din og navigere raskere rundt i Propdock.\n\nHer er en liste over alle snarveiene for å komme i gang:\n\n| Snarvei | Funksjon | Global |\n| ------- | ----------------------------- | ------ |\n| `n` | Opprett ny eiendom | Ja |\n| `s` | Søk etter eiendommer | Ja |\n| `p` | Gå til profil | Ja |\n| `k` | Åpne hjelpesenteret | Ja |\n| `/` | Fokuser på søkefeltet | Ja |\n| `e` | Rediger valgt eiendom | Nei |\n| `d` | Dupliser valgt eiendom | Nei |\n| `a` | Arkiver valgt eiendom | Nei |\n| `x` | Slett valgt eiendom | Nei |\n\nFor å åpne kommandomenyen når som helst, trykk `Cmd+K` (Mac) eller `Ctrl+K` (Windows/Linux).\n\nHar du forslag til nye snarveier? La oss vite ved å opprette en [ny sak](https://github.com/propdock/propdock/issues/new?title=Snarveiforslag&labels=forbedring) på vår GitHub-side.\n\nVi jobber kontinuerlig med å forbedre din opplevelse med Propdock. Følg med for flere oppdateringer!", + "content": "Vi introduserer tastatursnarveier på Propdock. Med snarveier kan du effektivisere arbeidsflyten din og navigere raskere rundt i Propdock.\r\n\r\nHer er en liste over alle snarveiene for å komme i gang:\r\n\r\n| Snarvei | Funksjon | Global |\r\n| ------- | ----------------------------- | ------ |\r\n| `n` | Opprett ny eiendom | Ja |\r\n| `s` | Søk etter eiendommer | Ja |\r\n| `p` | Gå til profil | Ja |\r\n| `k` | Åpne hjelpesenteret | Ja |\r\n| `/` | Fokuser på søkefeltet | Ja |\r\n| `e` | Rediger valgt eiendom | Nei |\r\n| `d` | Dupliser valgt eiendom | Nei |\r\n| `a` | Arkiver valgt eiendom | Nei |\r\n| `x` | Slett valgt eiendom | Nei |\r\n\r\nFor å åpne kommandomenyen når som helst, trykk `Cmd+K` (Mac) eller `Ctrl+K` (Windows/Linux).\r\n\r\nHar du forslag til nye snarveier? La oss vite ved å opprette en [ny sak](https://github.com/propdock/propdock/issues/new?title=Snarveiforslag&labels=forbedring) på vår GitHub-side.\r\n\r\nVi jobber kontinuerlig med å forbedre din opplevelse med Propdock. Følg med for flere oppdateringer!", "title": "Tastatursnarveier", "publishedAt": "2023-08-05", "summary": "Introduserer tastatursnarveier på Propdock", diff --git a/apps/www/.content-collections/generated/allCustomersPosts.js b/apps/www/.content-collections/generated/allCustomersPosts.js index 947abbb..b6ed54a 100644 --- a/apps/www/.content-collections/generated/allCustomersPosts.js +++ b/apps/www/.content-collections/generated/allCustomersPosts.js @@ -1,6 +1,6 @@ export default [ { - "content": "[Corponor](https://corponor.no/) er en ledende eiendomsutvikler basert i Bodø, Norge. Siden etableringen i 1994 har Corponor vært en betydelig aktør i Bodøs boligmarked, og har vokst til å bli den største og mest profesjonelle boligbyggeren i området.\n\nMed over 30 års erfaring og mer enn 1 400 bygde boliger, har Corponor utviklet omfattende ekspertise innen prosjektutvikling for både bolig- og nringseiendommer. De håndterer prosjekter fra start til slutt, fra råtomt til innflyttingsklare boliger.\n\n## Utfordringen: Effektiv håndtering av leieavtaler og fakturering\n\nEtter hvert som Corponor fortsatte å vokse og utvide sin eiendomsportefølje, møtte de utfordringer med å effektivt administrere sine leieforhold og økonomiske prosesser:\n\n1. **Oversikt over leieavtaler**: Med et økende antall leietakere ble det stadig mer komplisert å holde oversikt over kontraktsdetaljer, fornyelser og utløpsdatoer.\n2. **Manuell fakturahåndtering**: Fakturering av leietakere var en tidkrevende prosess som krevde mye manuelt arbeid og økte risikoen for feil.\n3. **Ressursbruk**: Betydelige ressurser ble brukt på administrative oppgaver knyttet til leieforvaltning og fakturering, noe som tok fokus vekk fra kjernevirksomheten.\n\n\n\n## Løsningen: En omfattende plattform for eiendomsforvaltning\n\nPropdock ga Corponor en alt-i-ett-løsning for å adressere deres utfordringer:\n\n1. **Effektiv leietakerhåndtering**: Propdock ga Corponor en helhetlig oversikt over alle leietakere, med automatiske påminnelser om når kontrakter nærmer seg utløp. Dette gjorde det enkelt å planlegge fornyelser eller nye utleieforhold i god tid.\n2. **Automatisert fakturering**: Med Propdocks integrerte faktureringsløsning kunne Corponor automatisere utsendingen av fakturaer til leietakere, noe som sparte tid og reduserte risikoen for menneskelige feil.\n3. **Forbedret økonomistyring**: Plattformen ga sanntidsoversikt over leieinntekter, utestående betalinger og økonomiske prognoser, noe som ga Corponor bedre kontroll over sin økonomiske situasjon.\n\n## Resultatene: Forbedret effektivitet og kundetilfredshet\n\nSiden implementeringen av Propdock har Corponor sett betydelige forbedringer i sin drift:\n1. **Optimalisert leietakerhåndtering**: Den helhetlige oversikten over leietakere og automatiske påminnelser om kontraktutløp har redusert tomgangsperioder og økt leieinntektene.\n2. **Effektivisert fakturering**: Automatisert fakturering har spart Corponor betydelig tid og redusert feil, noe som har ført til mer stabil kontantstrøm.\n3. **Forbedret økonomisk oversikt**: Sanntidsoversikt over leieinntekter og økonomiske prognoser har gitt Corponor bedre grunnlag for strategiske beslutninger.\n4. **Økt kundetilfredshet**: Forbedrede kommunikasjonsverktøy og mer effektiv håndtering av leieforhold har ført til høyere kundetilfredshet og smidigere prosesser for både leietakere og potensielle boligkjøpere.\n\n\n\nSom en Enterprise-kunde har Corponor kunnet utnytte Propdocks avanserte funksjoner for å ytterligere optimalisere driften. Plattformens skalerbarhet sikrer at etter hvert som Corponor fortsetter å vokse og ta på seg flere prosjekter, vil Propdock vokse med dem og støtte deres visjon om å utvikle Bodø i årene som kommer.", + "content": "[Corponor](https://corponor.no/) er en ledende eiendomsutvikler basert i Bodø, Norge. Siden etableringen i 1994 har Corponor vært en betydelig aktør i Bodøs boligmarked, og har vokst til å bli den største og mest profesjonelle boligbyggeren i området.\r\n\r\nMed over 30 års erfaring og mer enn 1 400 bygde boliger, har Corponor utviklet omfattende ekspertise innen prosjektutvikling for både bolig- og nringseiendommer. De håndterer prosjekter fra start til slutt, fra råtomt til innflyttingsklare boliger.\r\n\r\n## Utfordringen: Effektiv håndtering av leieavtaler og fakturering\r\n\r\nEtter hvert som Corponor fortsatte å vokse og utvide sin eiendomsportefølje, møtte de utfordringer med å effektivt administrere sine leieforhold og økonomiske prosesser:\r\n\r\n1. **Oversikt over leieavtaler**: Med et økende antall leietakere ble det stadig mer komplisert å holde oversikt over kontraktsdetaljer, fornyelser og utløpsdatoer.\r\n2. **Manuell fakturahåndtering**: Fakturering av leietakere var en tidkrevende prosess som krevde mye manuelt arbeid og økte risikoen for feil.\r\n3. **Ressursbruk**: Betydelige ressurser ble brukt på administrative oppgaver knyttet til leieforvaltning og fakturering, noe som tok fokus vekk fra kjernevirksomheten.\r\n\r\n\r\n\r\n## Løsningen: En omfattende plattform for eiendomsforvaltning\r\n\r\nPropdock ga Corponor en alt-i-ett-løsning for å adressere deres utfordringer:\r\n\r\n1. **Effektiv leietakerhåndtering**: Propdock ga Corponor en helhetlig oversikt over alle leietakere, med automatiske påminnelser om når kontrakter nærmer seg utløp. Dette gjorde det enkelt å planlegge fornyelser eller nye utleieforhold i god tid.\r\n2. **Automatisert fakturering**: Med Propdocks integrerte faktureringsløsning kunne Corponor automatisere utsendingen av fakturaer til leietakere, noe som sparte tid og reduserte risikoen for menneskelige feil.\r\n3. **Forbedret økonomistyring**: Plattformen ga sanntidsoversikt over leieinntekter, utestående betalinger og økonomiske prognoser, noe som ga Corponor bedre kontroll over sin økonomiske situasjon.\r\n\r\n## Resultatene: Forbedret effektivitet og kundetilfredshet\r\n\r\nSiden implementeringen av Propdock har Corponor sett betydelige forbedringer i sin drift:\r\n1. **Optimalisert leietakerhåndtering**: Den helhetlige oversikten over leietakere og automatiske påminnelser om kontraktutløp har redusert tomgangsperioder og økt leieinntektene.\r\n2. **Effektivisert fakturering**: Automatisert fakturering har spart Corponor betydelig tid og redusert feil, noe som har ført til mer stabil kontantstrøm.\r\n3. **Forbedret økonomisk oversikt**: Sanntidsoversikt over leieinntekter og økonomiske prognoser har gitt Corponor bedre grunnlag for strategiske beslutninger.\r\n4. **Økt kundetilfredshet**: Forbedrede kommunikasjonsverktøy og mer effektiv håndtering av leieforhold har ført til høyere kundetilfredshet og smidigere prosesser for både leietakere og potensielle boligkjøpere.\r\n\r\n\r\n\r\nSom en Enterprise-kunde har Corponor kunnet utnytte Propdocks avanserte funksjoner for å ytterligere optimalisere driften. Plattformens skalerbarhet sikrer at etter hvert som Corponor fortsetter å vokse og ta på seg flere prosjekter, vil Propdock vokse med dem og støtte deres visjon om å utvikle Bodø i årene som kommer.", "title": "Hvordan Corponor bruker Propdock for å effektivisere eiendomsutvikling", "publishedAt": "2024-08-05", "summary": "Corponor, en ledende eiendomsutvikler i Bodø, har integrert Propdock i sin arbeidsflyt. Lær hvordan det har forbedret deres prosjektstyring og kundekommunikasjon.", diff --git a/apps/www/.content-collections/generated/allHelpPosts.js b/apps/www/.content-collections/generated/allHelpPosts.js index ede82f4..72ddcd1 100644 --- a/apps/www/.content-collections/generated/allHelpPosts.js +++ b/apps/www/.content-collections/generated/allHelpPosts.js @@ -1,6 +1,6 @@ export default [ { - "content": "Velkommen til Propdock – den neste generasjonen innen eiendomsadministrasjon. Vi kombinerer kraftig analyse, brukervennlig grensesnitt og åpen kildekode for å gi deg den ultimate løsningen for eiendomsforvaltning.\n\n## Kraftig analyse for smarte beslutninger\n\nI hjertet av Propdock ligger vår avanserte analysemotor. Vi gir deg innsikt i:\n\n- Belegg og leieinntekter\n- Driftskostnader og vedlikeholdsutgifter\n- Markedstrender og verdivurderinger\n\nMed Propdock får du et 360-graders syn på din eiendomsportefølje, slik at du kan ta informerte beslutninger og maksimere avkastningen.\n\n\n\n## Sømløse integrasjoner\n\nPropdock er designet for å passe sømløst inn i ditt eksisterende økosystem. Vi tilbyr integrasjoner med:\n\n- Regnskapssystemer\n- CRM-verktøy\n- Vedlikeholdsplanleggere\n- Og mye mer!\n\nVår fleksible arkitektur sikrer at Propdock vokser med dine behov.\n\n## Åpen kildekode for maksimal fleksibilitet\n\nVi tror på transparens og fellesskap. Propdock er fullstendig åpen kildekode, noe som betyr at du kan:\n\n- Tilpasse plattformen til dine spesifikke behov\n- Bidra til utviklingen og forme fremtiden til Propdock\n- Ha full kontroll over dine data og prosesser\n\nBesøk vårt [GitHub-repository](https://github.com/codehagen/propdock) for å utforske koden og bli en del av fellesskapet.\n\n## Start din Propdock-reise i dag\n\nEnten du forvalter en enkelt eiendom eller en omfattende portefølje, er Propdock skapt for å forenkle din hverdag og forbedre dine resultater.\n\n[Kom i gang gratis](https://propdock.no/) og opplev fremtiden innen eiendomsadministrasjon.", + "content": "Velkommen til Propdock – den neste generasjonen innen eiendomsadministrasjon. Vi kombinerer kraftig analyse, brukervennlig grensesnitt og åpen kildekode for å gi deg den ultimate løsningen for eiendomsforvaltning.\r\n\r\n## Kraftig analyse for smarte beslutninger\r\n\r\nI hjertet av Propdock ligger vår avanserte analysemotor. Vi gir deg innsikt i:\r\n\r\n- Belegg og leieinntekter\r\n- Driftskostnader og vedlikeholdsutgifter\r\n- Markedstrender og verdivurderinger\r\n\r\nMed Propdock får du et 360-graders syn på din eiendomsportefølje, slik at du kan ta informerte beslutninger og maksimere avkastningen.\r\n\r\n\r\n\r\n## Sømløse integrasjoner\r\n\r\nPropdock er designet for å passe sømløst inn i ditt eksisterende økosystem. Vi tilbyr integrasjoner med:\r\n\r\n- Regnskapssystemer\r\n- CRM-verktøy\r\n- Vedlikeholdsplanleggere\r\n- Og mye mer!\r\n\r\nVår fleksible arkitektur sikrer at Propdock vokser med dine behov.\r\n\r\n## Åpen kildekode for maksimal fleksibilitet\r\n\r\nVi tror på transparens og fellesskap. Propdock er fullstendig åpen kildekode, noe som betyr at du kan:\r\n\r\n- Tilpasse plattformen til dine spesifikke behov\r\n- Bidra til utviklingen og forme fremtiden til Propdock\r\n- Ha full kontroll over dine data og prosesser\r\n\r\nBesøk vårt [GitHub-repository](https://github.com/codehagen/propdock) for å utforske koden og bli en del av fellesskapet.\r\n\r\n## Start din Propdock-reise i dag\r\n\r\nEnten du forvalter en enkelt eiendom eller en omfattende portefølje, er Propdock skapt for å forenkle din hverdag og forbedre dine resultater.\r\n\r\n[Kom i gang gratis](https://propdock.no/) og opplev fremtiden innen eiendomsadministrasjon.", "title": "Hva er Propdock?", "updatedAt": "2024-08-04", "summary": "Vi er stolte av å presentere Propdock, vår innovative lsning for eiendomsadministrasjon. Opplev fremtiden innen eiendomsforvaltning.", diff --git a/apps/www/.content-collections/generated/index.js b/apps/www/.content-collections/generated/index.js index e599ed4..dd3c3ed 100644 --- a/apps/www/.content-collections/generated/index.js +++ b/apps/www/.content-collections/generated/index.js @@ -1,4 +1,4 @@ -// generated by content-collections at Wed Aug 07 2024 08:13:51 GMT+0200 (Central European Summer Time) +// generated by content-collections at Wed Aug 07 2024 11:21:24 GMT+0200 (sentraleuropeisk sommertid) import allBlogPosts from "./allBlogPosts.js"; import allChangelogPosts from "./allChangelogPosts.js"; diff --git a/apps/www/.gitignore b/apps/www/.gitignore index ddb79ad..641898c 100644 --- a/apps/www/.gitignore +++ b/apps/www/.gitignore @@ -1,5 +1,7 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +.content-collections + # dependencies node_modules .pnp diff --git a/apps/www/src/components/buttons/AddBuildingSheet.tsx b/apps/www/src/components/buttons/AddBuildingSheet.tsx index 6f9e5d8..b1d860c 100644 --- a/apps/www/src/components/buttons/AddBuildingSheet.tsx +++ b/apps/www/src/components/buttons/AddBuildingSheet.tsx @@ -1,13 +1,22 @@ "use client" -import { useState } from "react" +import { useEffect, useRef, useState } from "react" import { createBuilding } from "@/actions/create-building" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import { toast } from "sonner" +import fetchProperties from "src/lib/address-search" import { z } from "zod" import { Button } from "@dingify/ui/components/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@dingify/ui/components/dropdown-menu" import { Form, FormControl, @@ -31,23 +40,25 @@ import { const BuildingSchema = z.object({ name: z.string().min(1, "Building Name is required"), address: z.string().optional(), - gnr: z.string().optional(), - bnr: z.string().optional(), - snr: z.string().optional(), - fnr: z.string().optional(), + gnr: z.coerce.string(), + bnr: z.coerce.string(), + snr: z.coerce.string(), + fnr: z.coerce.string(), }) export function AddBuildingSheet({ propertyId }) { + const [address, setAddress] = useState([]) + const [openSearch, setOpenSearch] = useState(false) const [isLoading, setIsLoading] = useState(false) const form = useForm({ resolver: zodResolver(BuildingSchema), defaultValues: { name: "", address: "", - gnr: undefined, - bnr: undefined, - snr: undefined, - fnr: undefined, + gnr: "", + bnr: "", + snr: "", + fnr: "", }, }) @@ -81,6 +92,59 @@ export function AddBuildingSheet({ propertyId }) { } } + async function handleSearchProperty(address: string) { + const data = await fetchProperties(address) + if (!address) { + setAddress([]) + setOpenSearch(false) + } + if (data?.adresser?.length) { + setAddress(data.adresser) + setOpenSearch(true) + } + } + + function handleEnterKey(event: any, data: any) { + if (event.key === "Enter") { + handleSelectAddress(data) + setOpenSearch(false) + } + if (event.key === "Esc") { + setOpenSearch(false) + } + } + + function handleSelectAddress(data: any) { + // Reset the form if user selects a new address + const savedName = form.getValues("name") + form.reset() + form.setValue("name", savedName) + if (data) { + form.setValue("address", data.adressetekst) + form.setValue("gnr", data.gardsnummer) + form.setValue("bnr", data.bruksnummer) + form.setValue("fnr", data.festenummer) + // form.setValue("snr", data.??) // There is no property called "snr/seksjonsnummer" - Could it be "bruksenhetsnummer" or "undernummer" (??) + } + setOpenSearch(false) + } + + const ulRef = useRef(null) + + // Close the dropdown if user clicks outside + useEffect(() => { + const handleClickOutside = (event) => { + if (ulRef.current && !ulRef.current.contains(event.target)) { + setOpenSearch(false) + } + } + + document.addEventListener("mousedown", handleClickOutside) + return () => { + document.removeEventListener("mousedown", handleClickOutside) + } + }, [ulRef]) + return ( @@ -102,7 +166,7 @@ export function AddBuildingSheet({ propertyId }) { Navn på bygg - + @@ -115,7 +179,59 @@ export function AddBuildingSheet({ propertyId }) { Address - +
+ { + if (event.key === "Escape") { + event.preventDefault() + event.stopPropagation() + setOpenSearch(false) + } + }} + onKeyUp={(e) => { + if (e.key === "Escape") return + void handleSearchProperty(e.currentTarget.value) + }} + placeholder="Address..." + {...field} + /> + +
diff --git a/apps/www/src/lib/address-search.ts b/apps/www/src/lib/address-search.ts new file mode 100644 index 0000000..0fa5b43 --- /dev/null +++ b/apps/www/src/lib/address-search.ts @@ -0,0 +1,9 @@ +export default async function fetchProperties(params: string) { + if (!params) return + const response = await fetch( + `https://ws.geonorge.no/adresser/v1/sok?sok=${params}&treffPerSide=50&side=0`, + ) + const data = await response.json() + + return data +}