Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Redis

การสร้าง JIRA Clone แบบโอเพ่นซอร์สด้วย Firebase, Upstash และ SvelteKit

ในโพสต์นี้ ฉันพูดถึงวิธีที่ฉันสร้างทางเลือกโอเพ่นซอร์สให้กับ Jira Kanban Board โดยใช้ Upstash, SvelteKit และ Firebase Storage

การสร้าง JIRA Clone แบบโอเพ่นซอร์สด้วย Firebase, Upstash และ SvelteKit

สิ่งที่เราจะใช้

  • SvelteKit (เส้นทาง UI และ API)
  • อัพสแตช (การดำเนินการ CRUD)
  • Tailwind CSS (การจัดรูปแบบ)
  • พื้นที่เก็บข้อมูล Firebase (พื้นที่เก็บข้อมูล [รูปภาพ, pdf ฯลฯ])
  • SvelteKit Auth โดย Auth.js

สิ่งที่คุณต้องการ

  • บัญชี Upstash เพื่อสร้างฐานข้อมูล
  • บัญชี Firebase เพื่อสร้างที่เก็บข้อมูล
  • การตั้งค่า Google OAuth 2.0 เพื่อรับข้อมูลรับรอง OAuth

การตั้งค่า Upstash Redis

เมื่อคุณสร้างบัญชี Upstash และเข้าสู่ระบบแล้ว คุณจะไปที่แท็บ Redis และสร้างฐานข้อมูล

การสร้าง JIRA Clone แบบโอเพ่นซอร์สด้วย Firebase, Upstash และ SvelteKit

การสร้าง JIRA Clone แบบโอเพ่นซอร์สด้วย Firebase, Upstash และ SvelteKit

หลังจากที่คุณสร้างฐานข้อมูลแล้ว คุณจะไปที่แท็บรายละเอียด เลื่อนลงไปจนกว่าคุณจะพบส่วนเชื่อมต่อฐานข้อมูลของคุณ คัดลอกเนื้อหาและบันทึกไว้ในที่ที่ปลอดภัย

การสร้าง JIRA Clone แบบโอเพ่นซอร์สด้วย Firebase, Upstash และ SvelteKit

เลื่อนลงไปจนกว่าคุณจะพบส่วน REST API และเลือกปุ่ม .env คัดลอกเนื้อหาและบันทึกไว้ในที่ที่ปลอดภัย

การสร้าง JIRA Clone แบบโอเพ่นซอร์สด้วย Firebase, Upstash และ SvelteKit

การตั้งค่าโครงการ

หากต้องการตั้งค่า เพียงโคลน repo ของแอปแล้วทำตามบทช่วยสอนนี้เพื่อเรียนรู้ทุกอย่างที่อยู่ในนั้น หากต้องการแยกโครงการ ให้รัน:

git clone https://github.com/rishi-raj-jain/jira-sveltekit-firebase-storage-upstash-starter
cd jira-sveltekit-firebase-storage-upstash-starter
npm install

เมื่อคุณโคลน repo แล้ว คุณจะต้องสร้างไฟล์ .env คุณกำลังจะเพิ่มรายการที่เราบันทึกไว้จากส่วนด้านบน

มันควรมีลักษณะดังนี้:

# .env
 
# Obtained from Google OAuth 2.0 setup
# https://support.google.com/cloud/answer/6158849?hl=en
GOOGLE_ID="..."
GOOGLE_SECRET="..."
 
# SvelteKit Auth
AUTH_SECRET="..." # A random 32 char string
AUTH_TRUST_HOST=true
 
# Obtained from Upstash as from the steps done above
UPSTASH_REDIS_REST_URL="your_upstash_redis_rest__url_from_above"
UPSTASH_REDIS_REST_TOKEN="your_upstash_redis_rest__token_from_above"
// firebase-adminsdk.json
// with the firebase config obtained from your firebase project
// Read more about firebase config
// https://firebase.google.com/docs/web/learn-more#config-object
 
{
 "type": "...",
 "project_id": "...",
 "private_key_id": "...",
 "private_key": "...",
 "client_email": "...",
 "client_id": "...",
 "auth_uri": "...",
 "token_uri": "...",
 "auth_provider_x509_cert_url": "...",
 "client_x509_cert_url": "...",
 "universe_domain": "...",
 "storageBucket": "..."
}

หลังจากขั้นตอนเหล่านี้ คุณควรจะสามารถเริ่มต้นสภาพแวดล้อมภายในเครื่องได้โดยใช้คำสั่งต่อไปนี้:

npm run dev

โครงสร้างพื้นที่เก็บข้อมูล

นี่คือโครงสร้างโฟลเดอร์หลักสำหรับโปรเจ็กต์ ฉันได้ยกกำลังสองเป็นสีแดงให้กับไฟล์ที่จะกล่าวถึงต่อไปในโพสต์นี้ที่เกี่ยวข้องกับการดำเนินการ CRUD, SvelteKit Auth และตัวจัดการการอัปโหลดไฟล์ พร้อมด้วยไฟล์ที่มีการอ้างอิง

การสร้าง JIRA Clone แบบโอเพ่นซอร์สด้วย Firebase, Upstash และ SvelteKit

การปกป้องฟังก์ชัน Edge ของ SvelteKit โดยการตรวจสอบผู้ใช้

การทำงานที่ยอดเยี่ยมโดยทีมงานที่ 07 ทำให้ Auth ด้วย SvelteKit ดำเนินการได้อย่างราบรื่น โครงการดำเนินการ:

การอนุญาตในทุกหน้าโดยใช้ Google OAuth 2.0

การใช้ Server Hooks ของ SvelteKit เราบังคับใช้การตรวจสอบสิทธิ์กับคำขอที่เข้ามาทั้งหมด (ไปยังหน้าใดก็ได้):

// File: @/hooks.server.ts
 
import Google from "@auth/core/providers/google";
import { SvelteKitAuth } from "@auth/sveltekit";
import type { Handle } from "@sveltejs/kit";
import { GOOGLE_ID, GOOGLE_SECRET } from "$env/static/private";
 
// Read more on
// https://kit.svelte.dev/docs/hooks#server-hooks-handle
export const handle = SvelteKitAuth({
 // @ts-ignore
 providers: [Google({ clientId: GOOGLE_ID, clientSecret: GOOGLE_SECRET })],
}) satisfies Handle;

การอนุญาตบนฟังก์ชัน Edge โดยใช้เซิร์ฟเวอร์ภายในของ SvelteKit

การใช้ Server Locals ของ SvelteKit ทำให้เราสามารถเลือกใช้เพื่อตรวจสอบว่าผู้ใช้ได้รับการรับรองความถูกต้องในการดำเนินการฝั่งเซิร์ฟเวอร์เท่านั้นหรือไม่ ด้านล่างนี้เป็นตัวอย่างของการใช้ในการตรวจสอบว่าผู้ใช้ได้รับการรับรองความถูกต้องหรือไม่ในขณะที่สร้างปัญหาใหม่:

import { json } from '@sveltejs/kit'
import { isAuth } from '@/lib/auth'
import type { RequestEvent } from './$types'
import { getTask, getTasks } from '@/lib/issues'
import type { LayoutServerLoadEvent } from '../routes/$types'
import type { RequestEvent, ServerLoadEvent } from '@sveltejs/kit'
 
// Get user session if available in event locals
const isAuth = async (event: LayoutServerLoadEvent | ServerLoadEvent | RequestEvent) => {
 const session = await event.locals.getSession()
 if (session?.user?.image) {
 return { session }
 }
 return false
}
 
export async function GET(event: RequestEvent) {
 // If user is not authenticated throw a 403
 if (!(await isAuth(event))) {
 return new Response(undefined, {
 status: 403
 })
 }
 const url = event.url
 const idSearchParam = url.searchParams.get('id')
 if (idSearchParam) {
 const res = await getTask(idSearchParam)
 return json(res)
 } else if (url.searchParams.get('all')) {
 const res = await getTasks()
 return json(res)
 }
 return new Response(JSON.stringify({ code: 0, error: 'Invalid Request.' }), {
 status: 400,
 headers: {
 'content-type': 'application/json'
 }
 })
}

ปัญหาการดำเนินการ CRUD ผ่าน Upstash Redis

ในส่วนนี้ เราจะเจาะลึกเกี่ยวกับวิธีการดึงข้อมูล การอัปเดต และการลบข้อมูลสำหรับแต่ละประเด็นบนบอร์ดคัมบัง เราใช้ Upstash DB อย่างต่อเนื่อง (ผ่าน 14 ) เพื่อดึงข้อมูล แสดง และรีเฟรชข้อมูล

getTask:การดึงฟังก์ชันข้อมูลปัญหา

 25  ฟังก์ชันใช้ 30 ของ Upstash  ผ่านทาง 48 เป็นกุญแจสำคัญในการส่งคำขอ API ไปยัง Upstash สำหรับปัญหาที่เกี่ยวข้อง ซึ่งระบุด้วย 57 ที่ไม่ซ้ำกัน . หากไม่มีปัญหานั้น (หรือมีข้อผิดพลาด) ฟังก์ชันจะถูกตั้งค่าให้ส่งคืนออบเจ็กต์ที่มี 66  เพื่อให้ผู้ใช้สามารถเปลี่ยนเส้นทางไปที่ 404 (ไม่พบปัญหา) โดยอัตโนมัติในเส้นทางไดนามิกของ SvelteKit

type Task = { [key: string]: any } | null;
 
// Get Issue Data
// File: @/lib/issues/get.ts
export async function getTask(id: string) {
 try {
 const redis = (await import("../upstash/setup")).default;
 const task: Task = await redis.hget("issues", id);
 if (!task) {
 return {
 code: 0,
 error: "No such issue found.",
 };
 }
 return { ...task, code: 1 };
 } catch (e: any) {
 const error = e.message || e.toString();
 console.log(error);
 return {
 code: 0,
 error,
 };
 }
}

ในทำนองเดียวกัน การดำเนินการ CRUD ที่เหลือจะเป็นดังนี้:

// Create Issue
// File: @/lib/issues/create.ts
export async function createTask(info: any) {
 try {
 const redis = (await import("../upstash/setup")).default;
 const id =
 Math.random().toString().slice(2) + new Date().getUTCMilliseconds();
 await redis.hset("issues", { [id]: info });
 return { code: 1, id, message: "Issue Created Succesfully ✅" };
 } catch (e: any) {
 const error = e.message || e.toString();
 console.log(error);
 return {
 code: 0,
 error,
 };
 }
}
// Delete Issue
// File: @/lib/issues/delete.ts
export async function deleteTask(id: string) {
 try {
 const redis = (await import("../upstash/setup")).default;
 await redis.hdel("issues", id);
 return { code: 1, message: "Deleted Succesfully!" };
 } catch (e: any) {
 const error = e.message || e.toString();
 console.log(error);
 return {
 code: 0,
 error,
 };
 }
}
// Update Issue Data
// File: @/lib/issues/update.ts
export async function updateTask(info: any, id: string) {
 try {
 const redis = (await import("../upstash/setup")).default;
 if (id) {
 const task = await redis.hget("issues", id);
 if (task) {
 await redis.hset("issues", { [id]: info });
 return { code: 1, message: "Updated Successfully" };
 }
 }
 return {
 code: 0,
 error: "No such issue was found.",
 };
 } catch (e: any) {
 const error = e.message || e.toString();
 console.log(error);
 return {
 code: 0,
 error,
 };
 }
}

การจำกัดอัตรา

หากต้องการใช้การจำกัดอัตราที่ Edge เราใช้ 79 ไคลเอ็นต์ฐานข้อมูลและไลบรารีตัวจำกัดอัตราที่เรียกว่า 89 .

// Reference Function to ratelimiting
// File: @/lib/upstash/ratelimit.ts
import { Ratelimit } from "@upstash/ratelimit";
 
import redis from "./setup";
 
export const ratelimit = {
 upload: new Ratelimit({
 redis,
 limiter: Ratelimit.slidingWindow(2, "60s"),
 }),
 issues: new Ratelimit({
 redis,
 limiter: Ratelimit.slidingWindow(5, "60s"),
 }),
};

การใช้การจำกัดอัตราทำให้ฉันสามารถบรรลุสิ่งต่อไปนี้:

ก. ขีดจำกัดจำนวนการสร้างปัญหาต่อผู้ใช้ต่อนาที

เมื่อใช้การจำกัดอัตรา ฉันสามารถจำกัดการสร้างปัญหาห้ารายการต่อผู้ใช้ที่ได้รับการรับรองความถูกต้องต่อนาที เราสามารถบังคับใช้ขีดจำกัดอัตรานี้ตามอีเมลผู้ใช้ของผู้ใช้ที่ได้รับการตรวจสอบสิทธิ์

// File: @/routes/api/issue/+server.ts
// Issue Creation POST API SvelteKit Handler
import { ratelimit } from "@/lib/upstash/ratelimit";
 
export async function POST(event: RequestEvent) {
 const user = await isAuth(event);
 if (!user) {
 return new Response(undefined, {
 status: 403,
 });
 }
 if (user.session.user?.email) {
 // Look at the user email of authenticated user at edge
 // Rate limit 5 issues creation per minute
 const result = await ratelimit.issues.limit(user.session.user.email);
 if (!result.success) {
 return new Response(
 JSON.stringify({
 code: 0,
 error: `You can't create more than 5 issues per minute.`,
 }),
 {
 status: 403,
 headers: {
 "content-type": "application/json",
 },
 },
 );
 }
 const { info } = await event.request.json();
 const res = await createTask(info);
 return json(res);
 }
 return new Response(undefined, {
 status: 403,
 });
}

บี จำกัดจำนวนการอัพโหลดไฟล์ต่อผู้ใช้ต่อฉบับต่อนาที

เมื่อใช้การจำกัดอัตรา ฉันสามารถจำกัดการอัปโหลดไฟล์ได้สูงสุด 2 รายการต่อผู้ใช้ที่ได้รับการรับรองความถูกต้องต่องานต่อนาที เราสามารถบังคับใช้ขีดจำกัดอัตรานี้โดยอิงตามอีเมลผู้ใช้ของผู้ใช้ที่ได้รับการตรวจสอบสิทธิ์และ ID ของงาน เมื่อใดก็ตามที่การอัปโหลดเสร็จสมบูรณ์ เราจะอัปเดตงานใน Upstash DB โดยมี fileURL ต่อท้าย

// File: @/routes/api/content/+server.ts
// File Upload POST API SvelteKit Handler
import { ratelimit } from "@/lib/upstash/ratelimit";
 
export async function POST(event: RequestEvent) {
 // User Authentication Code
 if (user.session.user?.email) {
 // Validate User, Task ID and if a file is uploaded
 // Look at the user email of authenticated user and task's ID at edge
 // Rate limit 2 uploads per minute
 const result = await ratelimit.upload.limit(
 `${user.session.user.email}_${taskID}`,
 );
 if (!result.success) {
 return new Response(
 JSON.stringify({
 code: 0,
 error: `You can't upload more than 2 files per issue per minute.`,
 }),
 {
 status: 403,
 headers: {
 "content-type": "application/json",
 },
 },
 );
 }
 // File upload code
 // Continue reading the blog to see how
 // file uploads are being taken care of
 }
 return new Response(undefined, {
 status: 403,
 });
}

การจัดการการอัปโหลดและการดาวน์โหลดไฟล์ด้วย Firebase Storage

ในส่วนนี้ เราจะเจาะลึกถึงวิธีจัดการการอัพโหลดและการดาวน์โหลดไฟล์ของปัญหาในลักษณะที่ปลอดภัยและได้รับการรับรองความถูกต้องบน Edge ของ SvelteKit เราใช้ประโยชน์จากพื้นที่เก็บข้อมูล Firebase (v9) เพื่อดึงและอัปโหลดไฟล์ไป

โอ้ แต่ทำไมไม่ Cloudflare R2 เป็นพื้นที่จัดเก็บข้อมูลล่ะ

แม้ว่าฉันจะได้เห็นการสนับสนุนของชุมชนมากมายสำหรับแผนพื้นที่จัดเก็บข้อมูลฟรีของ Cloudflare R2 และข้อดีของแผนนี้ สิ่งหนึ่งที่ทำให้ฉันผิดหวังคือต้องนำบัตรเครดิตของฉันไปที่ Cloudflare ก่อนที่จะลองใช้ระบบเสียอีก สิ่งนี้ทำให้ฉันนึกถึงโซลูชันพื้นที่เก็บข้อมูลอื่นๆ และฉันตัดสินใจเลือก Firebase Storage ซึ่งให้พื้นที่เก็บข้อมูลฟรี 5 GB และในกรณีที่เกินนั้น บริการของฉันจะหยุดแทนการเรียกเก็บเงินจากบัตรเครดิตของฉันโดยไม่ได้รับการอนุมัติจากฉัน และรู้ว่าเกิดอะไรขึ้น

ฟังก์ชัน SvelteKit Edge เพื่ออัปโหลดไฟล์ไปยังที่เก็บข้อมูล Firebase

ในฟังก์ชัน Edge ต่อไปนี้ เรากำลังดูเหตุการณ์คำขอ POST ใดๆ และหากผู้ใช้ได้รับการรับรองความถูกต้อง เราก็จะได้รับ 93 และ 107 จาก formData ของเหตุการณ์ เมื่อดำเนินการเสร็จแล้ว เราจะประเมินเพิ่มเติมว่าจะดำเนินการต่อหรือไม่หากขนาดไฟล์ต่ำกว่า 5 MB เมื่อจัดการข้อกำหนดเบื้องต้นทั้งหมดแล้ว เราจะสร้าง ID ที่ไม่ซ้ำกัน จากนั้นสร้างการอ้างอิงของ firebase ไปยังโฟลเดอร์เฉพาะที่จะอัปโหลดไฟล์ไป ทันทีที่ไฟล์ถูกอัพโหลดไปยัง firebase มันจะส่งคืนมาพร้อมกับ URL ที่สามารถใช้เพื่อเข้าถึงไฟล์ที่อัพโหลด เราเพิ่ม URL ที่ไม่ซ้ำกันนี้ต่อท้าย 112 คีย์ข้อมูลของปัญหา

// File: @/routes/api/content/+server.ts
// File Upload POST API SvelteKit Handler
import { initializeApp } from "firebase/app";
import { getDownloadURL, getStorage, ref, uploadBytes } from "firebase/storage";
 
import fireBaseConfig from "../../../../firebase-adminsdk.json";
 
export async function POST(event: RequestEvent) {
 // User Authentication Code
 if (user.session.user?.email) {
 const app = initializeApp(fireBaseConfig);
 const storage = getStorage(app);
 const data = await event.request.formData();
 const taskID = data.get("taskID");
 const file = data.get("file");
 
 // ...Validate User, Task ID and if a file is uploaded
 // ...Rate Limiting Code
 
 // File Size Restriction(s)
 if (file.size > 5 * 1024 * 1024) {
 return new Response(
 JSON.stringify({
 code: 0,
 error: "File size exceeds the limit of 5 MB.",
 }),
 {
 status: 400,
 headers: {
 "content-type": "application/json",
 },
 },
 );
 }
 
 // Start File Upload Code
 try {
 // Create a unique ID
 const fileId = uuidv4();
 // If uploaded is not a File type
 if (!(file instanceof File)) return;
 // Create a ref to firebase storage
 const storageRef = ref(storage, `uploads/${fileId}/${file.name}`);
 // Obtain the arrayBuffer of the file uploaded
 const fileBuffer = await file.arrayBuffer();
 // Upload file to Firebase Storage in bytes using Uint8Array
 const { metadata } = await uploadBytes(
 storageRef,
 new Uint8Array(fileBuffer),
 );
 const { fullPath } = metadata;
 // No fullPath is received, the API errored out
 if (!fullPath) {
 return new Response(
 JSON.stringify({
 code: 0,
 error: `<span>There was some error while uploading the file.</span> <span class="mt-1 text-xs text-gray-500">Report an issue with the current URL that you are on and with the code XXX.</span>`,
 }),
 {
 status: 403,
 headers: {
 "content-type": "application/json",
 },
 },
 );
 }
 // If a file is uploaded successfully, append the file to list of attachments to the issue's data
 const { code, ...taskValues } = await getTask(taskID);
 if (code === 1) {
 if (taskValues) {
 if (taskValues.hasOwnProperty("files")) {
 taskValues["files"].push(
 `https://storage.googleapis.com/${storageRef.bucket}/${storageRef.fullPath}`,
 );
 } else {
 taskValues["files"] = [
 `https://storage.googleapis.com/${storageRef.bucket}/${storageRef.fullPath}`,
 ];
 }
 }
 // Update the task's data in Upstash
 await updateTask(taskValues, taskID);
 }
 return json({
 code: 1,
 message: "Uploaded Successfully",
 });
 } catch (error) {
 return new Response(
 JSON.stringify({ code: 0, error: error.message || error.toString() }),
 {
 status: 403,
 headers: {
 "content-type": "application/json",
 },
 },
 );
 }
 }
 return new Response(undefined, {
 status: 403,
 });
}

ฟังก์ชัน SvelteKit Edge เพื่อดาวน์โหลด URL สาธารณะของไฟล์จาก Firebase Storage

ดังที่คุณจำได้ เราได้เพิ่ม URL ที่ไม่ซ้ำซึ่งส่งคืนโดย Firebase ต่อท้ายใน 123 ของปัญหา ที่สำคัญ เราได้รับ URL ที่ไม่ซ้ำนั้นเป็นพารามิเตอร์รูปภาพในคำขอ GET ไปยังฟังก์ชัน Edge ของ SvelteKit เพื่อเรียกค้นไฟล์ต้นฉบับ เราใช้ฟังก์ชัน getDownloadURL จากไลบรารีของ firebase เพื่อรับ URL สาธารณะของสื่อต้นฉบับ

// File: @/routes/api/content/+server.ts
// File Upload GET API SvelteKit Handler
import { initializeApp } from "firebase/app";
import { getDownloadURL, getStorage, ref, uploadBytes } from "firebase/storage";
 
import fireBaseConfig from "../../../../firebase-adminsdk.json";
 
export async function GET(event: RequestEvent) {
 if (!(await isAuth(event))) {
 return new Response(undefined, {
 status: 403,
 });
 }
 const url = event.url;
 const image = url.searchParams.get("image");
 if (image) {
 try {
 const app = initializeApp(fireBaseConfig);
 const storage = getStorage(app);
 const fileRef = ref(storage, image);
 const imagePublicURL = await getDownloadURL(fileRef);
 return json({ code: 1, image: imagePublicURL });
 } catch (error) {
 return new Response(
 JSON.stringify({ code: 0, error: error.message || error.toString() }),
 {
 status: 500,
 headers: {
 "content-type": "application/json",
 },
 },
 );
 }
 }
 return new Response(JSON.stringify({ code: 0, error: "Invalid Request." }), {
 status: 400,
 headers: {
 "content-type": "application/json",
 },
 });
}

อย่างที่คุณคงคิดอยู่แล้ว อาจมีสื่อหลายรายการที่สามารถอัปโหลดได้ ดังนั้นเพื่อจัดการกับกรณีเล็กๆ น้อยๆ ของรูปภาพและวิดีโอ ฉันจึงได้เพิ่มสิ่งต่อไปนี้หากมีอย่างอื่นในส่วนหน้า:

<!-- File: @/routes/issue/[slug]/+page.svelte -->
 
{#each fieldFiles as file}
<div class="mt-8 w-full border border-white/25 p-3">
 {#if /\.(mp4|mov|mkv)/i.test(file)}
 <video class="h-auto w-full" src="{file}" controls>
 <track kind="captions" />
 </video>
 {:else}
 <img alt="{file}" src="{file}" class="h-auto w-full" />
 {/if}
</div>
{/each}

แต่เหตุใดจึงเป็นทางเลือกโอเพ่นซอร์สแทน Jira Kanban Board

มีประโยชน์มากมายที่จะทำให้คุณเลือกใช้ Jira Kanban Board ทางเลือกโอเพ่นซอร์ส แทนที่จะซื้อโซลูชันที่ต้องจ่ายเงินจำนวนมาก:

  • ประหยัดต้นทุนได้มาก:ประโยชน์ที่สำคัญที่สุดประการหนึ่งของการใช้ทางเลือกโอเพ่นซอร์สคือการประหยัดต้นทุน ต่างจากโซลูชันบอร์ด Kanban แบบชำระเงินอย่าง Jira ตรงที่ทางเลือกโอเพ่นซอร์สที่สร้างด้วย SvelteKit, TailwindCSS, Firebase Storage, Serverless DB ของ Upstash และการจำกัดอัตราสามารถใช้งานได้โดยไม่มีค่าธรรมเนียมใบอนุญาตใดๆ
  • ความสามารถในการปรับแต่งได้ไม่จำกัด:ด้วยทางเลือกโอเพ่นซอร์ส คุณสามารถควบคุมโค้ดเบสได้อย่างเต็มที่ และสามารถปรับแต่งบอร์ด Kanban ได้ตามความต้องการเฉพาะของคุณ ความยืดหยุ่นนี้มักไม่สามารถทำได้กับโซลูชันแบบชำระเงินที่มีตัวเลือกการปรับแต่งที่จำกัด
  • ความง่ายในการบูรณาการ:คุณสามารถใช้ประโยชน์จากประสิทธิภาพของ API เพื่อเชื่อมต่อบอร์ด Kanban ของคุณกับระบบการจัดการโครงการ เครื่องมือควบคุมเวอร์ชัน บริการแจ้งเตือน และอื่นๆ นอกจากนี้ ลักษณะโอเพ่นซอร์สของโครงการช่วยให้นักพัฒนาสามารถขยายฟังก์ชันการทำงานและสร้างปลั๊กอินหรือการผสานรวมที่ปรับให้เหมาะกับความต้องการเฉพาะของพวกเขาได้

บทสรุป

โดยสรุป โปรเจ็กต์นี้ได้มอบประสบการณ์อันมีค่าในการใช้งานการจำกัดอัตราแบบละเอียด การดำเนินการข้อมูล CRUD ใช้งาน Firebase Storage API เพื่อรับและอัปโหลดไฟล์ ทั้งหมดนี้ทำที่ Edge ด้วย 133 ของ Upstash ห้องสมุด!