├── .gitignore ├── backend ├── .dockerignore ├── .gitignore ├── pkg │ ├── db │ │ ├── migrations │ │ │ ├── 000001_create_users.down.sql │ │ │ ├── 000003_create_posts.down.sql │ │ │ ├── 000002_create_sessions.down.sql │ │ │ ├── 000006_create_comments.down.sql │ │ │ ├── 000007_create_messages.down.sql │ │ │ ├── 000008_create_groups.down.sql │ │ │ ├── 000004_create_followers.down.sql │ │ │ ├── 000017_create_notifications.down.sql │ │ │ ├── 000005_create_post_shares.down.sql │ │ │ ├── 000009_create_group_members.down.sql │ │ │ ├── 000010_create_group_posts.down.sql │ │ │ ├── 000012_create_group_events.down.sql │ │ │ ├── 000013_create_event_options.down.sql │ │ │ ├── 000016_create_group_comments.down.sql │ │ │ ├── 000014_create_post_reactions.down.sql │ │ │ ├── 000011_create_group_message_notifications.down.sql │ │ │ ├── 000015_create_group_reactions.down.sql │ │ │ ├── 000018_create_group_index.down.sql │ │ │ ├── 000002_create_sessions.up.sql │ │ │ ├── 000005_create_post_shares.up.sql │ │ │ ├── 000003_create_posts.up.sql │ │ │ ├── 000010_create_group_posts.up.sql │ │ │ ├── 000004_create_followers.up.sql │ │ │ ├── 000008_create_groups.up.sql │ │ │ ├── 000006_create_comments.up.sql │ │ │ ├── 000013_create_event_options.up.sql │ │ │ ├── 000011_create_group_message_notifications.up.sql │ │ │ ├── 000001_create_users.up.sql │ │ │ ├── 000014_create_post_reactions.up.sql │ │ │ ├── 000007_create_messages.up.sql │ │ │ ├── 000012_create_group_events.up.sql │ │ │ ├── 000016_create_group_comments.up.sql │ │ │ ├── 000018_create_group_index.up.sql │ │ │ ├── 000009_create_group_members.up.sql │ │ │ ├── 000015_create_group_reactions.up.sql │ │ │ └── 000017_create_notifications.up.sql │ │ └── sqlite │ │ │ └── sqlite.go │ ├── model │ │ ├── category.go │ │ ├── follow.go │ │ ├── member.go │ │ ├── conversation.go │ │ ├── comment.go │ │ ├── message.go │ │ ├── session.go │ │ ├── reaction.go │ │ ├── types.go │ │ ├── group.go │ │ ├── post.go │ │ ├── response │ │ │ ├── response.go │ │ │ └── types.go │ │ ├── event.go │ │ ├── response.go │ │ ├── user.go │ │ └── request │ │ │ ├── request.go │ │ │ ├── register.go │ │ │ └── types.go │ ├── controller │ │ ├── logout.go │ │ ├── register.go │ │ ├── validators.go │ │ ├── handlers.go │ │ ├── session.go │ │ ├── login.go │ │ ├── reaction.go │ │ ├── message.go │ │ ├── image.go │ │ ├── profile.go │ │ ├── groupReaction.go │ │ ├── comment.go │ │ ├── chat.go │ │ ├── follow.go │ │ ├── post.go │ │ ├── groupComment.go │ │ ├── postShare.go │ │ ├── notification.go │ │ └── app.go │ ├── server │ │ ├── handlerFunc.go │ │ ├── server.go │ │ └── start.go │ └── repository │ │ ├── sessioRepo.go │ │ ├── mainRepo.go │ │ ├── categoryRepo.go │ │ ├── commentRepo.go │ │ ├── groupCommentRepo.go │ │ ├── postShareRepo.go │ │ ├── groupReactionRepo.go │ │ └── reactionRepo.go ├── static │ ├── posts │ │ ├── 2148606c-2d2b-410b-a7bf-34a3f9dfa80d_images.jpeg │ │ ├── e2cca501-4de9-4005-8d7b-65fd3f86fef9_images.jpeg │ │ ├── e5074b1b-29ea-44b7-bf09-f2713efdcc76_gopher.png │ │ ├── 17d11e96-eacf-4303-b12c-c2db16d1ef5a_images-(1).jpeg │ │ └── 02aa71d1-590f-418e-bfbb-c3f217318782_b85f3a28fd572685b0dab45537113294.jpg │ ├── avatars │ │ ├── 09d12f1d-8c70-4be5-a23d-fd2d44398d6f_images.jpeg │ │ └── abecdf62-1981-4518-b345-67b89e607517_images-(1).jpeg │ ├── group-posts │ │ ├── b878dfa9-cf73-42f2-9eb0-45d9f16978f2_gopher.png │ │ ├── b9e87d80-f5c4-4c49-8005-e4d082fcef6b_gopher.png │ │ └── a9b4b471-2952-4537-af06-e8725a7577fb_nathan-rosengrun-20-weege.jpg │ ├── post-comments │ │ └── 5dddc628-1b3d-47b9-a0c1-a96b4215a4ed_gopher.png │ └── group-post-comments │ │ ├── a88d172e-d254-4c3c-963b-398bcb945cc3_images.jpeg │ │ └── bb7725d8-d4f2-4fb7-9251-1adf16546261_images-(1).jpeg ├── main.go ├── go.mod ├── Dockerfile └── go.sum ├── frontend ├── .dockerignore ├── app │ ├── favicon.ico │ ├── page.tsx │ ├── auth │ │ └── layout.tsx │ ├── home │ │ ├── layout.tsx │ │ ├── chat │ │ │ └── page.tsx │ │ └── (posts) │ │ │ └── page.tsx │ └── layout.tsx ├── types │ ├── follow.ts │ ├── comment.ts │ ├── message.ts │ ├── event.ts │ ├── chat.ts │ ├── post.ts │ ├── user.ts │ └── group.ts ├── public │ ├── vercel.svg │ ├── window.svg │ ├── file.svg │ ├── icons │ │ ├── left.svg │ │ ├── logout.svg │ │ ├── heart.svg │ │ ├── x.svg │ │ ├── placeholder.svg │ │ ├── lock.svg │ │ ├── send.svg │ │ ├── globe.svg │ │ ├── attachment.svg │ │ ├── messages.svg │ │ ├── broken-heart.svg │ │ └── users.svg │ ├── globe.svg │ └── next.svg ├── api │ ├── auth │ │ ├── getToken.ts │ │ ├── setSession.ts │ │ └── clearSessionCookie.ts │ └── messages │ │ ├── pass-getChatData.ts │ │ └── getMesages.ts ├── eslint.config.mjs ├── next.config.ts ├── middleware.ts ├── helpers │ ├── ErrorProvider.tsx │ ├── addMessage.ts │ ├── uploadFile.ts │ ├── GlobalAPIHelper.ts │ └── webSocket.ts ├── .gitignore ├── tsconfig.json ├── package.json ├── Dockerfile └── components │ ├── ConfirmationPopup.tsx │ ├── popup.tsx │ ├── ErrorPopup.tsx │ ├── group-comment.tsx │ ├── comment.tsx │ ├── create-event-modal.tsx │ ├── group-invite-modal.tsx │ ├── comment-form.tsx │ ├── create-group-modal.tsx │ └── create-post-modal.tsx ├── dockerRun.sh ├── ToDo └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | backend/pkg/db/forum.db 2 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | DOCKERFILE 2 | dockerignore -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | *.jpg 3 | *.jpeg 4 | *.png 5 | *.gif -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | DOCKERFILE 3 | dockerignore 4 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000001_create_users.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS users; 2 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000003_create_posts.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS posts; 2 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000002_create_sessions.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS sessions; 2 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000006_create_comments.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS comments; 2 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000007_create_messages.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS messages; 2 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000008_create_groups.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS groups; 2 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000004_create_followers.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS followers; 2 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000017_create_notifications.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS notifications; -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000005_create_post_shares.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS post_shares; 2 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000009_create_group_members.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS group_members; 2 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000010_create_group_posts.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS group_posts; 2 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000012_create_group_events.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS group_events; 2 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000013_create_event_options.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS event_options; 2 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000016_create_group_comments.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS group_comments; -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000014_create_post_reactions.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS post_reactions; 2 | -------------------------------------------------------------------------------- /frontend/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/frontend/app/favicon.ico -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000011_create_group_message_notifications.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS group_message_notifications; 2 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000015_create_group_reactions.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS group_post_reactions; 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /frontend/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | export default function Home() { 4 | redirect("/home"); 5 | } 6 | -------------------------------------------------------------------------------- /frontend/types/follow.ts: -------------------------------------------------------------------------------- 1 | export interface Follow { 2 | id: number; 3 | followerId: number; 4 | followingId: number; 5 | isAccepted: number; 6 | } -------------------------------------------------------------------------------- /frontend/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/app/auth/layout.tsx: -------------------------------------------------------------------------------- 1 | export default async function AppLayout({ children }: { children: React.ReactNode }) { 2 | 3 | return <>{children} 4 | } 5 | -------------------------------------------------------------------------------- /backend/pkg/model/category.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Category struct { 4 | ID int `json:"id"` 5 | Name string `json:"category"` 6 | Post []*Post `json:"posts"` 7 | } 8 | -------------------------------------------------------------------------------- /backend/static/posts/2148606c-2d2b-410b-a7bf-34a3f9dfa80d_images.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/posts/2148606c-2d2b-410b-a7bf-34a3f9dfa80d_images.jpeg -------------------------------------------------------------------------------- /backend/static/posts/e2cca501-4de9-4005-8d7b-65fd3f86fef9_images.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/posts/e2cca501-4de9-4005-8d7b-65fd3f86fef9_images.jpeg -------------------------------------------------------------------------------- /backend/static/posts/e5074b1b-29ea-44b7-bf09-f2713efdcc76_gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/posts/e5074b1b-29ea-44b7-bf09-f2713efdcc76_gopher.png -------------------------------------------------------------------------------- /backend/static/avatars/09d12f1d-8c70-4be5-a23d-fd2d44398d6f_images.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/avatars/09d12f1d-8c70-4be5-a23d-fd2d44398d6f_images.jpeg -------------------------------------------------------------------------------- /backend/static/group-posts/b878dfa9-cf73-42f2-9eb0-45d9f16978f2_gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/group-posts/b878dfa9-cf73-42f2-9eb0-45d9f16978f2_gopher.png -------------------------------------------------------------------------------- /backend/static/group-posts/b9e87d80-f5c4-4c49-8005-e4d082fcef6b_gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/group-posts/b9e87d80-f5c4-4c49-8005-e4d082fcef6b_gopher.png -------------------------------------------------------------------------------- /backend/static/posts/17d11e96-eacf-4303-b12c-c2db16d1ef5a_images-(1).jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/posts/17d11e96-eacf-4303-b12c-c2db16d1ef5a_images-(1).jpeg -------------------------------------------------------------------------------- /backend/static/avatars/abecdf62-1981-4518-b345-67b89e607517_images-(1).jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/avatars/abecdf62-1981-4518-b345-67b89e607517_images-(1).jpeg -------------------------------------------------------------------------------- /backend/static/post-comments/5dddc628-1b3d-47b9-a0c1-a96b4215a4ed_gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/post-comments/5dddc628-1b3d-47b9-a0c1-a96b4215a4ed_gopher.png -------------------------------------------------------------------------------- /backend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "social-network/pkg/server" 6 | ) 7 | 8 | func main() { 9 | if err := server.Start(); err != nil { 10 | log.Fatal(err) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/static/group-post-comments/a88d172e-d254-4c3c-963b-398bcb945cc3_images.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/group-post-comments/a88d172e-d254-4c3c-963b-398bcb945cc3_images.jpeg -------------------------------------------------------------------------------- /backend/static/group-post-comments/bb7725d8-d4f2-4fb7-9251-1adf16546261_images-(1).jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/group-post-comments/bb7725d8-d4f2-4fb7-9251-1adf16546261_images-(1).jpeg -------------------------------------------------------------------------------- /backend/static/group-posts/a9b4b471-2952-4537-af06-e8725a7577fb_nathan-rosengrun-20-weege.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/group-posts/a9b4b471-2952-4537-af06-e8725a7577fb_nathan-rosengrun-20-weege.jpg -------------------------------------------------------------------------------- /backend/static/posts/02aa71d1-590f-418e-bfbb-c3f217318782_b85f3a28fd572685b0dab45537113294.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfarssi/social-network2/HEAD/backend/static/posts/02aa71d1-590f-418e-bfbb-c3f217318782_b85f3a28fd572685b0dab45537113294.jpg -------------------------------------------------------------------------------- /backend/pkg/model/follow.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Follow struct { 4 | ID int `json:"id"` 5 | FollowerId int `json:"followerId"` 6 | FollowingId int `json:"followingId"` 7 | IsAccepted bool `json:"isAccepted"` 8 | } 9 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000018_create_group_index.down.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS idx_group_post_reactions_user_id; 2 | DROP INDEX IF EXISTS idx_group_comments_post_id; 3 | DROP INDEX IF EXISTS idx_group_comments_user_id; 4 | DROP INDEX IF EXISTS idx_notifications_receiver_group; -------------------------------------------------------------------------------- /dockerRun.sh: -------------------------------------------------------------------------------- 1 | docker rm -f $(docker ps -aq) && docker rmi -f $(docker images -aq) 2 | 3 | cd frontend 4 | docker build -t frontend . 5 | docker run -d -p 3000:3000 --name frontend frontend 6 | 7 | cd ../backend 8 | docker build -t backend . 9 | docker run -d -p 8080:8080 --name backend backend -------------------------------------------------------------------------------- /frontend/api/auth/getToken.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cookies } from "next/headers"; 4 | 5 | const getToken = async () => { 6 | const cookieStore = await cookies(); 7 | return cookieStore.get("token")?.value || ""; 8 | }; 9 | 10 | export default getToken; 11 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000002_create_sessions.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS sessions ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | user_id INTEGER, 4 | session TEXT, 5 | expiresAt TEXT, 6 | FOREIGN KEY (user_id) REFERENCES users (id) ON UPDATE CASCADE ON DELETE CASCADE 7 | ); 8 | -------------------------------------------------------------------------------- /frontend/types/comment.ts: -------------------------------------------------------------------------------- 1 | export interface Comment { 2 | id: number; 3 | author: string; 4 | image?: string; 5 | user: { 6 | name: string; 7 | avatar: string; 8 | }; 9 | text: string; 10 | creation_date: string; 11 | user_id?: number; 12 | user_avatar?: number; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/app/home/layout.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from "@/components/Navbar"; 2 | 3 | export default async function AppLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode; 7 | }) { 8 | return ( 9 | <> 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000005_create_post_shares.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS post_shares ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | post_id INTEGER, 4 | shared_with_user_id INTEGER, 5 | FOREIGN KEY (post_id) REFERENCES posts (id), 6 | FOREIGN KEY (shared_with_user_id) REFERENCES users (id) 7 | ); 8 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000003_create_posts.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS posts ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | privacy TEXT, 4 | user_id INTEGER, 5 | caption TEXT, 6 | image TEXT, 7 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP, 8 | FOREIGN KEY (user_id) REFERENCES users (id) 9 | ); 10 | -------------------------------------------------------------------------------- /frontend/api/auth/setSession.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { cookies } from 'next/headers' 4 | 5 | async function setSessionCookie(token: string) { 6 | (await cookies()).set('token', token, { 7 | path: '/', 8 | httpOnly: true, 9 | maxAge: 60 * 60 * 24, 10 | }) 11 | } 12 | 13 | export default setSessionCookie -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000010_create_group_posts.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS group_posts ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | user_id INTEGER, 4 | group_id INTEGER, 5 | caption TEXT, 6 | image TEXT, 7 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP, 8 | FOREIGN KEY (user_id) REFERENCES users (id) 9 | ); 10 | -------------------------------------------------------------------------------- /backend/pkg/model/member.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type GroupMember struct { 4 | ID int `json:"id"` 5 | UserId int `json:"user_id"` 6 | InviterId int `json:"inviter_id"` 7 | GroupId int `json:"group_id"` 8 | IsAccepted bool `json:"is_accepted"` 9 | CreatingDate string `json:"creation_date"` 10 | } 11 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000004_create_followers.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS followers ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | follower_id INTEGER, 4 | following_id INTEGER, 5 | is_accepted BOOLEAN NOT NULL DEFAULT FALSE, 6 | FOREIGN KEY (follower_id) REFERENCES users (id), 7 | FOREIGN KEY (following_id) REFERENCES users (id) 8 | ); 9 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000008_create_groups.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS groups ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | user_id INTEGER NOT NULL, 4 | title TEXT NOT NULL, 5 | description TEXT NOT NULL, 6 | image TEXT NOT NULL, 7 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP, 8 | FOREIGN KEY (user_id) REFERENCES users (id) 9 | ); 10 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000006_create_comments.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS comments ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | user_id INTEGER, 4 | post_id INTEGER, 5 | text TEXT, 6 | image TEXT, 7 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP, 8 | FOREIGN KEY (user_id) REFERENCES users (id), 9 | FOREIGN KEY (post_id) REFERENCES posts (id) 10 | ); 11 | -------------------------------------------------------------------------------- /frontend/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/types/message.ts: -------------------------------------------------------------------------------- 1 | import { User } from "./user"; 2 | 3 | export interface Message { 4 | id: number; 5 | sender_id: number; 6 | recipient_id?: number; 7 | group_id?: number; 8 | text: string; 9 | created_at: string; 10 | isOwned: boolean; 11 | user: User; 12 | } 13 | 14 | export interface AddMessageEvent { 15 | type: string; 16 | message: Message; 17 | isOwned: boolean; 18 | } 19 | -------------------------------------------------------------------------------- /backend/pkg/model/conversation.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Conv struct { 4 | GroupId int `json:"groupId"` 5 | UserId int `json:"userId"` 6 | Image string `json:"image"` 7 | FullName string `json:"fullName"` 8 | Unreadcount int `json:"unreadcount"` 9 | LastMessage string `json:"lastmessage"` 10 | LastMessageDate string `json:"lastmessagedate"` 11 | } 12 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000013_create_event_options.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS event_options ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | event_id INTEGER NOT NULL, 4 | user_id INTEGER NOT NULL, 5 | is_going BOOLEAN NOT NULL, 6 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP, 7 | FOREIGN KEY (user_id) REFERENCES users (id), 8 | FOREIGN KEY (event_id) REFERENCES group_events (id) 9 | ); 10 | -------------------------------------------------------------------------------- /backend/pkg/model/comment.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Comment struct { 4 | ID int `json:"id"` 5 | UserId int `json:"user_id"` 6 | UserAvatar string `json:"user_avatar"` 7 | PostId int `json:"post_id"` 8 | Author string `json:"author"` 9 | Text string `json:"text"` 10 | Image string `json:"image"` 11 | CreationDate string `json:"creation_date"` 12 | } 13 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000011_create_group_message_notifications.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS group_message_notifications ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | group_id INTEGER, 4 | user_id INTEGER, 5 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP, 6 | is_seen BOOLEAN DEFAULT FALSE, 7 | FOREIGN KEY (user_id) REFERENCES users (id), 8 | FOREIGN KEY (group_id) REFERENCES groups (id) 9 | ); 10 | -------------------------------------------------------------------------------- /frontend/api/auth/clearSessionCookie.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cookies } from "next/headers"; 4 | 5 | async function clearSessionCookie() { 6 | try { 7 | const cookieStore = await cookies(); 8 | 9 | cookieStore.delete("token"); 10 | 11 | return { success: true }; 12 | } catch (error) { 13 | console.error("Error clearing session cookie:", error); 14 | } 15 | } 16 | 17 | export default clearSessionCookie; 18 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000001_create_users.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | email TEXT NOT NULL UNIQUE, 4 | password TEXT NOT NULL, 5 | firstname TEXT NOT NULL, 6 | lastname TEXT NOT NULL, 7 | birth DATE NOT NULL, 8 | nickname TEXT UNIQUE, 9 | avatar TEXT, 10 | about TEXT, 11 | is_private BOOLEAN NOT NULL DEFAULT FALSE, 12 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP 13 | ); -------------------------------------------------------------------------------- /frontend/types/event.ts: -------------------------------------------------------------------------------- 1 | import { User } from "./user"; 2 | 3 | export interface GroupEvent { 4 | id: number; 5 | title: string; 6 | description: string; 7 | user_id: number; 8 | group_id: number; 9 | date: string; 10 | place: string; 11 | option_1: string; 12 | option_2: string; 13 | creation_date: string; 14 | user: User; 15 | current_option?: string; 16 | opt1_users?: User[] | null; 17 | opt2_users?: User[] | null; 18 | } 19 | -------------------------------------------------------------------------------- /backend/pkg/model/message.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Message struct { 4 | ID int `json:"id"` 5 | SenderId int `json:"sender_id"` 6 | RecipientId int `json:"recipient_id"` 7 | IsOwned bool `json:"isOwned"` 8 | GroupId int `json:"group_id"` 9 | IsSeen int `json:"is_seen"` 10 | Text string `json:"text"` 11 | User *User `json:"user"` 12 | CreationDate string `json:"created_at"` 13 | } 14 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module social-network 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/golang-migrate/migrate/v4 v4.18.3 7 | github.com/google/uuid v1.6.0 8 | github.com/gorilla/websocket v1.5.0 9 | github.com/mattn/go-sqlite3 v1.14.28 10 | golang.org/x/crypto v0.36.0 11 | ) 12 | 13 | require ( 14 | github.com/hashicorp/errwrap v1.1.0 // indirect 15 | github.com/hashicorp/go-multierror v1.1.1 // indirect 16 | go.uber.org/atomic v1.7.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /backend/pkg/model/session.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | type Session struct { 10 | UserID int 11 | Session string 12 | ExpiresAt time.Time 13 | } 14 | 15 | func CreateSession(userID int) *Session { 16 | session := uuid.NewString() 17 | expiresAt := time.Now().Add(12000 * time.Second) 18 | return &Session{UserID: userID, Session: session, ExpiresAt: expiresAt} 19 | } 20 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000014_create_post_reactions.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS post_reactions ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | post_id INTEGER NOT NULL, 4 | user_id INTEGER NOT NULL, 5 | is_like BOOLEAN, 6 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP, 7 | FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE, 8 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, 9 | UNIQUE(post_id, user_id) 10 | ); 11 | -------------------------------------------------------------------------------- /backend/pkg/model/reaction.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Reaction struct { 4 | ID int `json:"id"` 5 | PostID int `json:"post_id"` 6 | UserID int `json:"user_id"` 7 | IsLike *bool `json:"is_like"` 8 | CreationDate string `json:"creation_date"` 9 | } 10 | 11 | type ReactionCounts struct { 12 | Likes int `json:"likes"` 13 | Dislikes int `json:"dislikes"` 14 | UserReaction *bool `json:"userReaction"` 15 | } 16 | 17 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000007_create_messages.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS messages ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | sender_id INTEGER, 4 | recipient_id INTEGER, 5 | group_id INTEGER, 6 | is_seen INTEGER DEFAULT 0, 7 | text TEXT, 8 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP, 9 | FOREIGN KEY (sender_id) REFERENCES users (id), 10 | FOREIGN KEY (recipient_id) REFERENCES users (id), 11 | FOREIGN KEY (group_id) REFERENCES groups (id) 12 | ); 13 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000012_create_group_events.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS group_events ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | group_id INTEGER, 4 | user_id INTEGER, 5 | title TEXT, 6 | description TEXT, 7 | option_1 TEXT, 8 | option_2 TEXT, 9 | date DATETIME, 10 | place TEXT, 11 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP, 12 | FOREIGN KEY (user_id) REFERENCES users (id), 13 | FOREIGN KEY (group_id) REFERENCES groups (id) 14 | ); 15 | -------------------------------------------------------------------------------- /frontend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000016_create_group_comments.up.sql: -------------------------------------------------------------------------------- 1 | -- Create group_comments table 2 | CREATE TABLE IF NOT EXISTS group_comments ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | user_id INTEGER NOT NULL, 5 | post_id INTEGER NOT NULL, 6 | text TEXT NOT NULL, 7 | image TEXT DEFAULT '', 8 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP, 9 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, 10 | FOREIGN KEY (post_id) REFERENCES group_posts(id) ON DELETE CASCADE 11 | ); -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000018_create_group_index.up.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX IF NOT EXISTS idx_group_post_reactions_post_id ON group_post_reactions(post_id); 2 | CREATE INDEX IF NOT EXISTS idx_group_post_reactions_user_id ON group_post_reactions(user_id); 3 | CREATE INDEX IF NOT EXISTS idx_group_comments_post_id ON group_comments(post_id); 4 | CREATE INDEX IF NOT EXISTS idx_group_comments_user_id ON group_comments(user_id); 5 | CREATE INDEX IF NOT EXISTS idx_notifications_receiver_group ON notifications(receiver_id, group_id); 6 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-bullseye AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod go.sum ./ 6 | RUN go mod download 7 | 8 | COPY . . 9 | 10 | RUN go build -o backend main.go 11 | 12 | FROM debian:bullseye-slim 13 | 14 | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* 15 | 16 | WORKDIR /root/ 17 | 18 | COPY --from=builder /app/backend . 19 | COPY --from=builder /app/pkg/db/migrations ./pkg/db/migrations 20 | 21 | EXPOSE 8080 22 | 23 | CMD ["./backend"] 24 | -------------------------------------------------------------------------------- /frontend/types/chat.ts: -------------------------------------------------------------------------------- 1 | // API response Chat 2 | export interface ResChat { 3 | groupId: number; 4 | userId: number; 5 | image: string; 6 | fullName: string; 7 | unreadcount: number; 8 | lastmessagedate: string; 9 | } 10 | 11 | // React Chat Interface 12 | export interface Chat { 13 | id: string; 14 | name: string; 15 | avatar: string; 16 | lastMessage?: string; 17 | lastMessageTime?: string; 18 | unreadCount: number; 19 | isGroup: boolean; 20 | isNew: boolean; 21 | isOnline?: boolean; 22 | } -------------------------------------------------------------------------------- /frontend/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | reactStrictMode: false, 5 | images: { 6 | remotePatterns: [ 7 | { 8 | protocol: "http", 9 | hostname: "localhost", 10 | port: "8080", 11 | pathname: "/getProtectedImage", 12 | }, 13 | ], 14 | }, 15 | experimental: { 16 | serverActions: { 17 | bodySizeLimit: "100mb", 18 | }, 19 | }, 20 | }; 21 | 22 | export default nextConfig; 23 | -------------------------------------------------------------------------------- /backend/pkg/controller/logout.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "log" 5 | "social-network/pkg/model/request" 6 | "social-network/pkg/model/response" 7 | ) 8 | 9 | func (app *App) Logout(payload *request.RequestT) any { 10 | 11 | session := payload.Ctx.Value("token").(string) 12 | err := app.repository.Session().RemoveSession(session) 13 | if err != nil { 14 | log.Println("Failed to remove session:", err) 15 | } 16 | 17 | return &response.Logout{ 18 | Message: "Session was removed!", 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import type { NextRequest } from "next/server"; 3 | 4 | export function middleware(request: NextRequest) { 5 | const token = request.cookies.get("token")?.value; 6 | 7 | const isProtected = 8 | request.nextUrl.pathname.startsWith("/home") || 9 | request.nextUrl.pathname === "/"; 10 | 11 | if (!token && isProtected) { 12 | return NextResponse.redirect(new URL("/auth", request.url)); 13 | } 14 | 15 | return NextResponse.next(); 16 | } 17 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000009_create_group_members.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS group_members ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | user_id INTEGER NOT NULL, 4 | inviter_id INTEGER NOT NULL, 5 | group_id INTEGER NOT NULL, 6 | is_accepted BOOLEAN NOT NULL, 7 | is_seen BOOLEAN DEFAULT FALSE, 8 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP, 9 | FOREIGN KEY (user_id) REFERENCES users (id), 10 | FOREIGN KEY (inviter_id) REFERENCES users (id), 11 | FOREIGN KEY (group_id) REFERENCES groups (id) 12 | ); 13 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000015_create_group_reactions.up.sql: -------------------------------------------------------------------------------- 1 | -- Create group_post_reactions table 2 | CREATE TABLE IF NOT EXISTS group_post_reactions ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | user_id INTEGER NOT NULL, 5 | post_id INTEGER NOT NULL, 6 | is_like BOOLEAN NOT NULL, 7 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP, 8 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, 9 | FOREIGN KEY (post_id) REFERENCES group_posts(id) ON DELETE CASCADE, 10 | UNIQUE(user_id, post_id) 11 | ); 12 | 13 | 14 | -------------------------------------------------------------------------------- /ToDo: -------------------------------------------------------------------------------- 1 | ToDo : 2 | 3 | # Chat 4 | - display 10 messages at a time with a debounce(throttle) of 1 second 5 | # Groups 6 | 7 | 8 | # Single Profile Page 9 | 10 | # Middlewares 11 | - canSendMessage 12 | - isMember / is Owner (groups hanlders) 13 | 14 | 15 | # Problems 16 | - logout 17 | 18 | 19 | 20 | 21 | #install font-awesome 22 | npm install @fortawesome/fontawesome-free 23 | npm install --save @fortawesome/react-fontawesome @fortawesome/free-solid-svg-icons @fortawesome/fontawesome-svg-core 24 | 25 | 26 | golangci-lint run ./... 27 | gocritic check ./... -------------------------------------------------------------------------------- /frontend/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import ErrorPopup from "@/components/ErrorPopup"; 3 | import "@/styles/globals.css"; 4 | 5 | export const metadata: Metadata = { 6 | title: "Social Network", 7 | description: "Generated by create next app", 8 | }; 9 | 10 | export default function RootLayout({ 11 | children, 12 | }: Readonly<{ 13 | children: React.ReactNode; 14 | }>) { 15 | return ( 16 | 17 | 18 | {children} 19 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /frontend/public/icons/left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /backend/pkg/db/migrations/000017_create_notifications.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS notifications ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | sender_id INTEGER NOT NULL, 4 | receiver_id INTEGER NOT NULL, 5 | type TEXT NOT NULL, -- 'follow_request', 'event_created', 'event_response' 6 | group_id INTEGER, 7 | is_seen BOOLEAN DEFAULT FALSE, 8 | 9 | 10 | creation_date DATETIME DEFAULT CURRENT_TIMESTAMP, 11 | FOREIGN KEY (sender_id) REFERENCES users (id), 12 | FOREIGN KEY (receiver_id) REFERENCES users (id) 13 | FOREIGN KEY (group_id) REFERENCES groups(id) 14 | ); 15 | -------------------------------------------------------------------------------- /frontend/app/home/chat/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import ChatList from "@/components/chatList"; 5 | import ChatWindow from "@/components/chatWindow"; 6 | import { Chat } from "@/types/chat"; 7 | 8 | export default function ChatPage() { 9 | const [activeChat, setActiveChat] = useState(null); 10 | 11 | return ( 12 |
13 |
14 | 15 | 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /frontend/types/post.ts: -------------------------------------------------------------------------------- 1 | import { Comment } from "./comment"; 2 | import { User } from "./user"; 3 | 4 | export interface Post { 5 | id: number; 6 | user: User; 7 | user_id?: number; 8 | image?: string; 9 | caption?: string; 10 | privacy?: string; 11 | creation_date?: string; 12 | reactions?: Reaction; 13 | comments?: Comment[]; 14 | comment_count?: number; 15 | } 16 | 17 | export interface Reaction { 18 | likes: number; 19 | dislikes: number; 20 | userReaction: boolean | null; 21 | isReacting?: boolean; 22 | } 23 | 24 | export interface PostShareModalProps { 25 | postId: number; 26 | isOpen: boolean; 27 | onClose: () => void; 28 | } 29 | -------------------------------------------------------------------------------- /backend/pkg/model/types.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | const ( 4 | TYPE_REGISTER = "register" 5 | TYPE_LOGIN = "login" 6 | Type_GET_POSTS = "get-posts" 7 | Type_GET_POST = "get-post" 8 | Type_ADD_POST = "add-post" 9 | Type_REACT_TO_POST = "react-to-post" 10 | Type_GET_POST_SHARES = "get-post-shares" 11 | Type_ADD_POST_SHARE = "add-post-share" 12 | Type_REMOVE_POST_SHARE = "remove-post-share" 13 | Type_ADD_COMMENT = "add-comment" 14 | Type_GET_COMMENTS = "get-comments" 15 | Type_GET_PROFILE = "get-profile" 16 | Type_GET_PROFILES = "get-profiles" 17 | Type_SET_PROFILE_PRIVACY = "set-privacy" 18 | ) 19 | -------------------------------------------------------------------------------- /frontend/helpers/ErrorProvider.tsx: -------------------------------------------------------------------------------- 1 | // Define the error structure 2 | export interface AppError { 3 | message: string; 4 | code?: number; 5 | } 6 | 7 | // Global error handler function reference 8 | let globalErrorHandler: (error: AppError) => void = () => {}; 9 | 10 | // Setter for global error handler 11 | export const setGlobalErrorHandler = (fn: (error: AppError) => void) => { 12 | globalErrorHandler = fn; 13 | }; 14 | 15 | // Getter for global error handler 16 | export const getGlobalErrorHandler = () => globalErrorHandler; 17 | 18 | // Convenience function to trigger global error 19 | export const showGlobalError = (message: string, code?: number) => { 20 | globalErrorHandler({ message, code }); 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /backend/pkg/model/group.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | type Group struct { 6 | ID int `json:"id"` 7 | OwnerId int `json:"owner_id"` 8 | Members []*User `json:"members"` 9 | Posts []*Post `json:"posts"` 10 | Events []*GroupEvent `json:"events"` 11 | Title string `json:"title"` 12 | Description string `json:"description"` 13 | Image string `json:"image"` 14 | CreationDate time.Time `json:"creation_date"` 15 | IsOwner bool `json:"is_owner"` 16 | IsAccepted bool `json:"is_accepted"` 17 | IsPending bool `json:"is_pending"` 18 | HasNewEvent bool `json:"new_event"` 19 | Inviter User `json:"inviter"` 20 | } 21 | -------------------------------------------------------------------------------- /frontend/public/icons/logout.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "helpers/cssProvider.tsx"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /backend/pkg/model/post.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Post struct { 4 | ID int `json:"id"` 5 | UserId int `json:"user_id"` 6 | Author string `json:"author"` 7 | CategoryID int `json:"category_id"` 8 | Title string `json:"title"` 9 | Text string `json:"text"` 10 | CreationDate string `json:"creation_date"` 11 | Comment []*Comment `json:"comments"` 12 | Privacy string `json:"privacy"` 13 | Caption string `json:"caption"` 14 | Image string `json:"image"` 15 | User *User `json:"user"` 16 | Reactions ReactionCounts `json:"reactions"` 17 | CommentCount int `json:"comment_count"` 18 | } 19 | -------------------------------------------------------------------------------- /frontend/helpers/addMessage.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import getToken from "@/api/auth/getToken"; 4 | import { socket } from "./webSocket"; 5 | import { showGlobalError } from "@/helpers/ErrorProvider"; 6 | 7 | export const addMessage = async ( 8 | id: number, 9 | isGroup: boolean, 10 | message: string 11 | ) => { 12 | if (!socket) return; 13 | 14 | try { 15 | const token = await getToken(); 16 | if (!token) { 17 | showGlobalError("Invalid session. Please log in again.", 401); 18 | return; 19 | } 20 | 21 | socket.send( 22 | JSON.stringify({ 23 | type: "add-message", 24 | data: { session: token, isGroup, id, message }, 25 | }) 26 | ); 27 | } catch (err) { 28 | console.error(err); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /backend/pkg/controller/register.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "social-network/pkg/model/request" 5 | "social-network/pkg/model/response" 6 | ) 7 | 8 | func (app *App) Register(payload *request.RequestT) any { 9 | data, ok := payload.Data.(*request.Register) 10 | if !ok { 11 | return response.Error{Code: 400, Cause: "Invalid payload type"} 12 | } 13 | 14 | err := data.Validate() 15 | if err != nil { 16 | return err 17 | } 18 | 19 | id, err := app.repository.User().Create(data) 20 | if err != nil { 21 | return err 22 | } 23 | session, sessionErr := app.repository.Session().Create(id) 24 | if sessionErr != nil { 25 | return response.Error{Code: 500, Cause: "Failed to create a session!"} 26 | } 27 | 28 | return &response.Login{ 29 | Session: session, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/types/user.ts: -------------------------------------------------------------------------------- 1 | import { Follow } from "./follow"; 2 | import { Post } from "./post"; 3 | 4 | export interface User { 5 | id: number; 6 | username?: string; 7 | firstname: string; 8 | lastname: string; 9 | nickname: string; 10 | avatar?: string; 11 | online?: boolean; 12 | isaccepted?: boolean; 13 | } 14 | 15 | export interface Profile { 16 | id: number; 17 | firstname: string; 18 | lastname: string; 19 | nickname: string; 20 | email: string; 21 | birth: string; 22 | avatar: string; 23 | about: string; 24 | online: boolean; 25 | isprivate: boolean; 26 | isaccepted: boolean; 27 | follow: Follow; 28 | posts: Post[]; 29 | followers: User[] | null; 30 | following: User[] | null; 31 | currentuser: boolean; 32 | totalnotifications: number; 33 | lastmessagedate: string; 34 | } 35 | -------------------------------------------------------------------------------- /backend/pkg/server/handlerFunc.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "social-network/pkg/model/request" 6 | "social-network/pkg/model/response" 7 | ) 8 | 9 | type HandlerFunc func(*request.RequestT) any 10 | 11 | func (h HandlerFunc) ServeHTTP(w http.ResponseWriter, request *request.RequestT) { 12 | res := h(request) 13 | status, body := response.Marshal(res) 14 | w.Header().Set("Content-Type", "application/json") 15 | w.WriteHeader(status) 16 | w.Write(body) 17 | } 18 | 19 | func (h HandlerFunc) ApplyMiddlewares(request *request.RequestT) http.Handler { 20 | var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | h.ServeHTTP(w, request) 22 | }) 23 | 24 | for _, mw := range request.Middlewares { 25 | handler = mw(handler, request) 26 | } 27 | 28 | return handler 29 | 30 | } 31 | -------------------------------------------------------------------------------- /backend/pkg/db/sqlite/sqlite.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | 7 | "github.com/golang-migrate/migrate/v4" 8 | _ "github.com/golang-migrate/migrate/v4/database/sqlite3" 9 | _ "github.com/golang-migrate/migrate/v4/source/file" 10 | _ "github.com/mattn/go-sqlite3" 11 | ) 12 | 13 | func InitDB(databasePath string) (*sql.DB) { 14 | 15 | runMigrations() 16 | db, err := sql.Open("sqlite3", databasePath) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | return db 21 | } 22 | 23 | func runMigrations() { 24 | m, err := migrate.New( 25 | "file://pkg/db/migrations", 26 | "sqlite3://pkg/db/forum.db", 27 | ) 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | 32 | if err := m.Up(); err != nil && err != migrate.ErrNoChange { 33 | log.Fatal(err) 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /frontend/api/messages/pass-getChatData.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { cookies } from 'next/headers' 4 | 5 | const getChatData = async () => { 6 | try { 7 | const cookieStore = await cookies() 8 | const token = cookieStore.get('token')?.value || '' 9 | 10 | const response = await fetch("http://localhost:8080/getChatData", { 11 | method: "POST", 12 | headers: { 13 | "Content-Type": "application/json", 14 | }, 15 | body: JSON.stringify({ 16 | session: token 17 | }), 18 | }); 19 | 20 | const data = await response.json(); 21 | 22 | if (data.error == "Invalid session") { 23 | cookieStore.delete('token'); 24 | } 25 | 26 | // console.log(data) 27 | return data; 28 | } catch (err) { 29 | console.error(err); 30 | } 31 | }; 32 | 33 | export default getChatData -------------------------------------------------------------------------------- /backend/pkg/controller/validators.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "regexp" 5 | 6 | "golang.org/x/crypto/bcrypt" 7 | ) 8 | 9 | func IsUsernameValid(username string) bool { 10 | re := regexp.MustCompile(`^[A-Za-z]{3,20}$`) 11 | return re.MatchString(username) 12 | } 13 | 14 | func IsPasswordValid(password string) bool { 15 | return len(password) >= 6 && len(password) <= 50 && regexp.MustCompile(`[A-Za-z]`).MatchString(password) && regexp.MustCompile(`\d`).MatchString(password) 16 | 17 | } 18 | 19 | func IsEmailValid(email string) bool { 20 | re := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,50}$`) 21 | return re.MatchString(email) 22 | } 23 | 24 | func ComparePasswords(hashedPassword string, clearPassword string) bool { 25 | err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(clearPassword)) 26 | return err == nil 27 | } 28 | -------------------------------------------------------------------------------- /backend/pkg/model/response/response.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type Payload struct { 8 | Type string `json:"type,omitempty"` 9 | Data any `json:"data,omitempty"` 10 | Error any `json:"error,omitempty"` 11 | } 12 | type Errored interface { 13 | getCode() int 14 | } 15 | 16 | type Error struct { 17 | Code int `json:"code,omitempty"` 18 | Cause string `json:"cause,omitempty"` 19 | } 20 | 21 | func (e *Error) getCode() int { return e.Code } 22 | 23 | func Marshal(data any) (status int, result []byte) { 24 | r := Payload{} 25 | status = 200 26 | switch v := data.(type) { 27 | case Errored: 28 | status = v.getCode() 29 | r.Error = data 30 | default: 31 | r.Data = data 32 | } 33 | 34 | result, err := json.Marshal(r) 35 | if err != nil { 36 | return 500, []byte("Error When Marshal Response") 37 | } 38 | return status, result 39 | } 40 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@fortawesome/fontawesome-free": "^6.7.2", 13 | "@fortawesome/fontawesome-svg-core": "^6.7.2", 14 | "@fortawesome/free-solid-svg-icons": "^6.7.2", 15 | "@fortawesome/react-fontawesome": "^0.2.2", 16 | "next": "^15.3.3", 17 | "npm": "^11.4.1", 18 | "react": "^19.0.0", 19 | "react-dom": "^19.0.0" 20 | }, 21 | "devDependencies": { 22 | "@eslint/eslintrc": "^3", 23 | "@types/node": "^20", 24 | "@types/react": "^19", 25 | "@types/react-dom": "^19", 26 | "eslint": "^9", 27 | "eslint-config-next": "15.3.1", 28 | "typescript": "^5" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /backend/pkg/model/event.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type GroupEvent struct { 4 | ID int `json:"id"` 5 | UserId int `json:"user_id"` 6 | User *User `json:"user"` 7 | GroupId int `json:"group_id"` 8 | Title string `json:"title"` 9 | Description string `json:"description"` 10 | Option1 string `json:"option_1"` 11 | Opt1Users []*User `json:"opt1_users"` 12 | Option2 string `json:"option_2"` 13 | Opt2Users []*User `json:"opt2_users"` 14 | Date string `json:"date"` 15 | Place string `json:"place"` 16 | CurrentOption string `json:"current_option"` 17 | CreationDate string `json:"creation_date"` 18 | } 19 | 20 | type EventOption struct { 21 | ID int `json:"id"` 22 | EventId int `json:"event_id"` 23 | User *User `json:"user"` 24 | IsGoing bool `json:"isGoing"` 25 | } 26 | -------------------------------------------------------------------------------- /frontend/public/icons/heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/api/messages/getMesages.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cookies } from "next/headers"; 4 | 5 | const getMessages = async (id: number, isGroup: boolean) => { 6 | try { 7 | const cookieStore = await cookies(); 8 | const token = cookieStore.get("token")?.value || ""; 9 | 10 | const response = await fetch("http://localhost:8080/getMessages", { 11 | method: "POST", 12 | headers: { 13 | "Content-Type": "application/json", 14 | }, 15 | body: JSON.stringify({ 16 | session: token, 17 | id: id, 18 | isGroup, 19 | }), 20 | }); 21 | const data = await response.json(); 22 | 23 | // console.log(data); 24 | 25 | if (data.error == "Invalid session") { 26 | cookieStore.delete("token"); 27 | } 28 | return data; 29 | } catch (err) { 30 | console.error(err); 31 | } 32 | }; 33 | 34 | export default getMessages; 35 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Base image 2 | FROM node:20-alpine AS base 3 | WORKDIR /app 4 | RUN apk add --no-cache libc6-compat 5 | 6 | # === Install dependencies (cached if package.json unchanged) 7 | FROM base AS deps 8 | COPY package*.json ./ 9 | RUN npm ci 10 | 11 | # === Build app 12 | FROM base AS builder 13 | WORKDIR /app 14 | COPY --from=deps /app/node_modules ./node_modules 15 | COPY . . 16 | RUN npm run build 17 | 18 | # === Production runner 19 | FROM base AS runner 20 | WORKDIR /app 21 | 22 | ENV NODE_ENV=production 23 | EXPOSE 3000 24 | 25 | # Copy only what's needed for production 26 | COPY --from=builder /app/public ./public 27 | COPY --from=builder /app/.next ./.next 28 | COPY --from=builder /app/node_modules ./node_modules 29 | COPY --from=builder /app/package.json ./package.json 30 | 31 | # Use non-root user if needed (optional) 32 | # RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 -G nodejs 33 | # USER nextjs 34 | 35 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /frontend/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/pkg/controller/handlers.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | ) 7 | 8 | func (app *App) WebSocketHandler(w http.ResponseWriter, r *http.Request) { 9 | 10 | if r.Header.Get("Upgrade") == "websocket" { 11 | conn, err := app.upgrader.Upgrade(w, r, nil) 12 | if err != nil { 13 | log.Println(err.Error()) 14 | return 15 | } 16 | 17 | token := r.FormValue("session") 18 | uid, err := app.repository.Session().FindUserIDBySession(token) 19 | if err != nil { 20 | log.Println("Session was not found!") 21 | return 22 | } 23 | 24 | client := app.addClient(uid, token, conn) 25 | 26 | go app.readMessage(conn, client) 27 | 28 | } 29 | } 30 | 31 | 32 | func (app *App) ProtectedImageHandler(w http.ResponseWriter, r *http.Request) { 33 | fullPath, ok := r.Context().Value("fullPath").(string) 34 | 35 | if !ok || fullPath == "" { 36 | http.Error(w, "Internal server error", http.StatusInternalServerError) 37 | return 38 | } 39 | 40 | http.ServeFile(w, r, fullPath) 41 | } 42 | -------------------------------------------------------------------------------- /backend/pkg/model/response.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Response struct { 4 | Type string `json:"type"` 5 | Data string `json:"data"` 6 | Username string `json:"username"` 7 | Userid int `json:"userid"` 8 | Session string `json:"session"` 9 | Error string `json:"error"` 10 | AllUsers []*User `json:"allusers"` 11 | FollowRequests []*User `json:"followrequests"` 12 | Categories []*Category `json:"categories"` 13 | Posts []*Post `json:"posts"` 14 | Postid int `json:"postid"` 15 | TotalNotifications int `json:"totalnotifications"` 16 | Partnerid int `json:"partnerid"` 17 | Message *Message `json:"message"` 18 | Messages []*Message `json:"messages"` 19 | User *User `json:"user"` 20 | Success bool `json:"success"` 21 | CurrentUser *User `json:"currentuser"` 22 | } 23 | -------------------------------------------------------------------------------- /frontend/types/group.ts: -------------------------------------------------------------------------------- 1 | import { GroupEvent } from "./event"; 2 | import { Post } from "./post"; 3 | import { User } from "./user"; 4 | 5 | export interface Group { 6 | id: number; 7 | title: string; 8 | description: string; 9 | image: string; 10 | owner_id: number; 11 | is_owner: boolean; 12 | is_accepted: boolean; 13 | is_pending?: boolean; 14 | members: User[]; 15 | posts: Post[]; 16 | events: GroupEvent[]; 17 | new_event: boolean; 18 | } 19 | 20 | export interface GroupInvite { 21 | title: string; 22 | id: number; 23 | group_id: number; 24 | user_id: number; 25 | creation_date: string; 26 | group: Group; 27 | inviter: User; 28 | } 29 | 30 | export interface JoinRequest { 31 | id: number; 32 | title: string; 33 | description: string; 34 | image: string; 35 | creation_date: string; 36 | is_accepted: boolean; 37 | is_owner: boolean; 38 | members: User[]; 39 | } 40 | 41 | export interface GroupsData { 42 | all: Group[]; 43 | error: string; 44 | groupInvites: GroupInvite[] | null; 45 | joinRequests: JoinRequest[] | null; 46 | } 47 | -------------------------------------------------------------------------------- /frontend/public/icons/x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 12 | -------------------------------------------------------------------------------- /frontend/public/icons/placeholder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | no-image 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /backend/pkg/controller/session.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "log" 5 | "social-network/pkg/model" 6 | ) 7 | 8 | func (app *App) ValidateSession(request map[string]any) *model.Response { 9 | response := &model.Response{} 10 | response.Type = "session" 11 | response.Error = "error" 12 | 13 | var session string 14 | if sessionRaw, ok := request["session"]; !ok { 15 | response.Error = "Missing 'session' field" 16 | return response 17 | } else if session, ok = sessionRaw.(string); !ok { 18 | response.Error = "'session' must be a string" 19 | return response 20 | } 21 | 22 | uid, err := app.repository.Session().FindUserIDBySession(session) 23 | if err != nil { 24 | log.Println("Session was not found!") 25 | return response 26 | } 27 | 28 | foundUser, err := app.repository.User().Find(uid) 29 | if err != nil { 30 | log.Println("User was not found!") 31 | return response 32 | } 33 | 34 | response.Error = "" 35 | response.Userid = foundUser.ID 36 | response.User = foundUser 37 | response.Session = session 38 | 39 | response.Data = "" 40 | return response 41 | } 42 | -------------------------------------------------------------------------------- /backend/pkg/controller/login.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "log" 5 | "social-network/pkg/model/request" 6 | "social-network/pkg/model/response" 7 | ) 8 | 9 | func (app *App) Login(payload *request.RequestT) any { 10 | u, ok := payload.Data.(*request.Login) 11 | if !ok { 12 | return &response.Error{ 13 | Code: 400, 14 | Cause: "Invalid payload type", 15 | } 16 | } 17 | invalidCredError := &response.Error{Code: 400, Cause: "username or password invalid"} 18 | foundUser, err := app.repository.User().Find(u.Email) 19 | if err != nil { 20 | log.Println("Failed to find a user:", err) 21 | return invalidCredError 22 | } 23 | 24 | if !ComparePasswords(foundUser.EncryptedPassword, u.Password) { 25 | return invalidCredError 26 | } 27 | 28 | session, err := app.repository.Session().Create(foundUser.ID) 29 | if err != nil { 30 | log.Println("Failed to create a session:", err) 31 | return &response.Error{Code: 500, Cause: "Failed to create session"} 32 | } 33 | 34 | return &response.Login{ 35 | Session: session, 36 | UserId: foundUser.ID, 37 | Username: foundUser.Username, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/components/ConfirmationPopup.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | // import "@fortawesome/fontawesome-free/css/all.min.css"; 3 | 4 | interface ConfirmationPopupProps { 5 | message: string; 6 | onConfirm: () => void; 7 | onCancel: () => void; 8 | } 9 | 10 | const ConfirmationPopup: FC = ({ 11 | message, 12 | onConfirm, 13 | onCancel, 14 | }) => { 15 | return ( 16 |
17 |
18 |
19 |

20 | 21 |

22 |

{message}

23 |
24 |
25 | 28 | 31 |
32 |
33 |
34 | ); 35 | }; 36 | 37 | export default ConfirmationPopup; 38 | -------------------------------------------------------------------------------- /backend/pkg/model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | type User struct { 6 | ID int `json:"id"` 7 | Username string `json:"username"` 8 | Firstname string `json:"firstname"` 9 | Lastname string `json:"lastname"` 10 | Nickname *string `json:"nickname"` 11 | Password string `json:"password"` 12 | EncryptedPassword string `json:"-"` 13 | Email string `json:"email"` 14 | Gender string `json:"gender"` 15 | Birth time.Time `json:"birth"` 16 | Avatar string `json:"avatar"` 17 | About string `json:"about"` 18 | Online bool `json:"online"` 19 | IsPrivate bool `json:"isprivate"` 20 | IsAccepted bool `json:"isaccepted"` 21 | Follow Follow `json:"follow"` 22 | Posts []*Post `json:"posts"` 23 | Followers []*User `json:"followers"` 24 | Following []*User `json:"following"` 25 | CurrentUser bool `json:"currentuser"` 26 | TotalNotifications int `json:"totalnotifications"` 27 | LastMessageDate string `json:"lastmessagedate"` 28 | } 29 | -------------------------------------------------------------------------------- /frontend/public/icons/lock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/public/icons/send.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/popup.tsx: -------------------------------------------------------------------------------- 1 | // FC stands for Function Component 2 | import { FC, useEffect } from "react"; 3 | // import "@fortawesome/fontawesome-free/css/all.min.css"; 4 | 5 | interface PopupProps { 6 | message: string; 7 | status: "success" | "failure"; 8 | onClose: () => void; 9 | } 10 | 11 | const Popup: FC = ({ message, status, onClose }) => { 12 | const isSuccess = status === "success"; 13 | 14 | useEffect(() => { 15 | const timer = setTimeout(onClose, 3000); 16 | return () => clearTimeout(timer); 17 | }, [onClose]); 18 | 19 | return ( 20 |
21 |
24 |
25 |

26 | {isSuccess ? ( 27 | 28 | ) : ( 29 | 30 | )} 31 |

32 | 33 |

{message}

34 | 37 |
38 | 39 |
40 |
41 |
42 | ); 43 | }; 44 | 45 | export default Popup; 46 | -------------------------------------------------------------------------------- /backend/pkg/repository/sessioRepo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | type SessionRepository struct { 11 | Repository *Repository 12 | } 13 | 14 | func (r *SessionRepository) Create(id int) (string, error) { 15 | session := uuid.NewString() 16 | expiresAt := time.Now().Add(12000 * time.Second) 17 | _, err := r.Repository.db.Exec( 18 | "INSERT INTO sessions (user_id, session, expiresAt) VALUES (?, ?, ?)", 19 | id, 20 | session, 21 | expiresAt, 22 | ) 23 | if err != nil { 24 | return "", err 25 | } 26 | return session, nil 27 | } 28 | 29 | func (r *SessionRepository) FindUserIDBySession(session string) (int, error) { 30 | var uid int 31 | query := "SELECT user_id from sessions WHERE session = $1" 32 | if err := r.Repository.db.QueryRow(query, session).Scan( 33 | &uid); err != nil { 34 | if err == sql.ErrNoRows { 35 | return -1, sql.ErrNoRows 36 | } 37 | return -1, err 38 | } 39 | return uid, nil 40 | } 41 | 42 | func (r *SessionRepository) RemoveSession(session string) error { 43 | query := "DELETE FROM sessions WHERE session = $1" 44 | if err := r.Repository.db.QueryRow(query, session).Scan(); err != nil { 45 | if err == sql.ErrNoRows { 46 | return nil 47 | } else { 48 | return err 49 | } 50 | } 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /frontend/public/icons/globe.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/public/icons/attachment.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | attachment Created with Sketch Beta. 7 | -------------------------------------------------------------------------------- /frontend/components/ErrorPopup.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AppError, setGlobalErrorHandler } from "@/helpers/ErrorProvider"; 4 | import { useEffect, useState } from "react"; 5 | 6 | export default function ErrorPopup() { 7 | const [error, setError] = useState(null); 8 | 9 | const clearError = () => setError(null); 10 | 11 | useEffect(() => { 12 | setGlobalErrorHandler((err: AppError) => { 13 | setError(err); 14 | }); 15 | 16 | return () => { 17 | setGlobalErrorHandler(() => {}); // Cleanup 18 | }; 19 | }, []); 20 | 21 | if (!error) return null; 22 | 23 | return ( 24 |
25 |
e.stopPropagation()} 29 | > 30 |
31 |

Error {error.code || "❗"}

32 | 35 |
36 |
37 |

45 | {error.message} 46 |

47 |
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /backend/pkg/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "database/sql" 5 | "io" 6 | "net/http" 7 | "social-network/pkg/model/request" 8 | "social-network/pkg/model/response" 9 | "social-network/pkg/repository" 10 | 11 | controller "social-network/pkg/controller" 12 | ) 13 | 14 | type Server struct { 15 | router *http.ServeMux 16 | app *controller.App 17 | } 18 | 19 | func NewServer(router *http.ServeMux, db *sql.DB) *Server { 20 | return &Server{ 21 | router: router, 22 | app: controller.NewApp(repository.New(db)), 23 | } 24 | } 25 | 26 | func (s *Server) AddRoute(pattern string, handler func(*request.RequestT) any, middlewares ...func(http.Handler, *request.RequestT) http.Handler) { 27 | h := HandlerFunc(handler) 28 | s.router.HandleFunc(pattern, func(resp http.ResponseWriter, req *http.Request) { 29 | body, err := io.ReadAll(req.Body) 30 | if err != nil { 31 | s.app.ServeError(resp, &response.Error{Cause: "oops, something went wrong", Code: 500}) 32 | return 33 | } 34 | 35 | _, reqData, err := request.Unmarshal(body) 36 | if err != nil { 37 | s.app.ServeError(resp, &response.Error{Cause: err.Error(), Code: 500}) 38 | return 39 | } 40 | 41 | reqData.Middlewares = middlewares 42 | handler := h.ApplyMiddlewares(reqData) 43 | s.app.CookieMiddleware(handler, reqData).ServeHTTP(resp, req) 44 | 45 | }) 46 | } 47 | 48 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 49 | s.router.ServeHTTP(w, r) 50 | } 51 | -------------------------------------------------------------------------------- /backend/pkg/repository/mainRepo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | type Repository struct { 8 | db *sql.DB 9 | } 10 | 11 | func New(db *sql.DB) *Repository { 12 | return &Repository{db: db} 13 | } 14 | 15 | func (r *Repository) User() *UserRepository { 16 | return &UserRepository{ 17 | Repository: r, 18 | } 19 | } 20 | 21 | func (r *Repository) Follow() *FollowRepository { 22 | return &FollowRepository{ 23 | Repository: r, 24 | } 25 | } 26 | 27 | func (r *Repository) Session() *SessionRepository { 28 | return &SessionRepository{ 29 | Repository: r, 30 | } 31 | } 32 | 33 | func (r *Repository) Category() *CategoryRepository { 34 | return &CategoryRepository{ 35 | Repository: r, 36 | } 37 | } 38 | 39 | func (r *Repository) Post() *PostRepository { 40 | return &PostRepository{ 41 | Repository: r, 42 | } 43 | } 44 | 45 | func (r *Repository) PostShare() *PostShareRepository { 46 | return &PostShareRepository{ 47 | Repository: r, 48 | } 49 | } 50 | 51 | func (r *Repository) Comment() *CommentRepository { 52 | return &CommentRepository{ 53 | Repository: r, 54 | } 55 | } 56 | 57 | func (r *Repository) Message() *MessageRepository { 58 | return &MessageRepository{ 59 | Repository: r, 60 | } 61 | } 62 | 63 | func (r *Repository) Reaction() *ReactionRepository { 64 | return &ReactionRepository{ 65 | Repository: r, 66 | } 67 | } 68 | 69 | func (r *Repository) Group() *GroupRepository { 70 | return &GroupRepository{ 71 | Repository: r, 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /backend/pkg/controller/reaction.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "log" 5 | "social-network/pkg/model" 6 | "social-network/pkg/model/request" 7 | "social-network/pkg/model/response" 8 | ) 9 | 10 | func (app *App) ReactToPost(payload *request.RequestT) any { 11 | data, ok := payload.Data.(*request.ReactToPost) 12 | if !ok { 13 | return &response.Error{ 14 | Code: 400, Cause: "Invalid payload type", 15 | } 16 | } 17 | 18 | userId := payload.Ctx.Value("user_id").(int) 19 | 20 | err := app.repository.Reaction().UpsertReaction(userId, data.PostId, data.Reaction) 21 | if err != nil { 22 | log.Println("Error saving reaction:", err) 23 | return &response.Error{ 24 | Code: 500, Cause: "Error saving reaction: " + err.Error(), 25 | } 26 | } 27 | 28 | counts, err := app.repository.Reaction().GetReactionCounts(data.PostId) 29 | if err != nil { 30 | log.Println("Error getting reaction counts:", err) 31 | return &response.Error{ 32 | Code: 500, Cause: "Error getting reaction counts: " + err.Error(), 33 | } 34 | } 35 | 36 | userReaction, err := app.repository.Reaction().GetUserReaction(userId, data.PostId) 37 | if err != nil { 38 | log.Println("Error getting user reaction:", err) 39 | return &response.Error{ 40 | Code: 500, Cause: "Error getting user reaction: " + err.Error(), 41 | } 42 | } 43 | counts.UserReaction = userReaction 44 | 45 | return &response.ReactToPost{ 46 | Userid: userId, 47 | Post: &model.Post{ 48 | ID: data.PostId, 49 | Reactions: counts, 50 | }, 51 | Success: true, 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /backend/pkg/controller/message.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "social-network/pkg/model" 7 | "social-network/pkg/model/request" 8 | ) 9 | 10 | func (app *App) UpdateSeenMessage(isGroup bool, currentId, targetId int) { 11 | m := &model.Message{} 12 | 13 | // Assign after validation 14 | m.RecipientId = int(currentId) 15 | 16 | if isGroup { 17 | m.GroupId = int(targetId) 18 | err := app.repository.Message().UpdateGroupSeenMessages(m) 19 | if err != nil { 20 | log.Println("Error in updating seen messages!") 21 | } 22 | notification := map[string]any{ 23 | "type": "notifications", 24 | "messages": m.GroupId , 25 | } 26 | app.sendNotificationToUser(m.GroupId , notification) 27 | } else { 28 | m.SenderId = int(targetId) 29 | 30 | err := app.repository.Message().UpdatePmSeenMessages(m) 31 | if err != nil { 32 | log.Println("Error in updating seen messages!") 33 | } 34 | notification := map[string]any{ 35 | "type": "notifications", 36 | "message": m.RecipientId , 37 | } 38 | app.sendNotificationToUser(m.RecipientId , notification) 39 | } 40 | 41 | } 42 | 43 | func (app *App) UpdateSeenMessageWS(payload *request.RequestT) any { 44 | data, ok := payload.Data.(*request.UpdateSeenMessageWS) 45 | if !ok { 46 | fmt.Println("Invalid payload type") 47 | return nil 48 | } 49 | userId, ok := payload.Ctx.Value("user_id").(int) 50 | if !ok { 51 | fmt.Println("Invalid session") 52 | return nil 53 | } 54 | 55 | app.UpdateSeenMessage(data.IsGroup, userId, data.Id) 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /backend/pkg/repository/categoryRepo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "social-network/pkg/model" 5 | ) 6 | 7 | type CategoryRepository struct { 8 | Repository *Repository 9 | } 10 | 11 | func (r *CategoryRepository) Add(c *model.Category) error { 12 | return r.Repository.db.QueryRow( 13 | "INSERT INTO categories (category) VALUES ( ? ) RETURNING id", 14 | c.Name, 15 | ).Scan(&c.ID) 16 | } 17 | 18 | func (r *CategoryRepository) GetAll() ([]*model.Category, error) { 19 | var categories []*model.Category 20 | rows, err := r.Repository.db.Query( 21 | "SELECT id, category FROM categories ORDER BY category ASC", 22 | ) 23 | if err != nil { 24 | return nil, err 25 | } 26 | defer rows.Close() 27 | for rows.Next() { 28 | category := &model.Category{} 29 | err = rows.Scan(&category.ID, &category.Name) 30 | if err != nil { 31 | return nil, err 32 | } 33 | categories = append(categories, category) 34 | } 35 | if len(categories) == 0 { 36 | return []*model.Category{}, nil 37 | } 38 | return categories, nil 39 | } 40 | 41 | func (r *CategoryRepository) GetCategoryById(Id int) (*model.Category, error) { 42 | row := r.Repository.db.QueryRow("SELECT * FROM categories WHERE id = ?", Id) 43 | category := &model.Category{} 44 | if err := row.Scan(&category.ID, &category.Name); err != nil { 45 | return nil, err 46 | } 47 | return category, nil 48 | } 49 | 50 | func (r *CategoryRepository) GetCategoryByName(categoryName string) (*model.Category, error) { 51 | row := r.Repository.db.QueryRow("SELECT * FROM categories WHERE category = ?", categoryName) 52 | category := &model.Category{} 53 | if err := row.Scan(&category.ID, &category.Name); err != nil { 54 | return nil, err 55 | } 56 | return category, nil 57 | } 58 | -------------------------------------------------------------------------------- /frontend/public/icons/messages.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/components/group-comment.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Comment } from "@/types/comment"; 4 | import Image from "next/image"; 5 | 6 | interface CommentProps { 7 | comment: Comment; 8 | postID: number; 9 | groupID?: number; 10 | } 11 | 12 | export default function GroupComment({ comment, postID, groupID }: CommentProps) { 13 | return ( 14 |
15 |
16 | {`user 30 |
31 |
32 |
33 | {comment.author} 34 | {comment.text && {comment.text}} 35 |
36 | 37 | {comment.image && ( 38 |
39 | Comment image 53 |
54 | )} 55 | 56 |
{comment.creation_date}
57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /frontend/components/comment.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Comment as CommentType } from "@/types/comment"; 4 | import Image from "next/image"; 5 | 6 | interface CommentProps { 7 | comment: CommentType; 8 | postID: number; 9 | } 10 | 11 | export default function Comment({ comment, postID }: CommentProps) { 12 | console.log("COMMENT DATA ----------------------", comment); 13 | return ( 14 |
15 |
16 | Comment image 30 |
31 |
32 |
33 | {comment.author} 34 | {comment.text && {comment.text}} 35 |
36 | 37 | {comment.image && ( 38 |
39 | Comment image 53 |
54 | )} 55 | 56 |
{comment.creation_date}
57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /backend/pkg/controller/image.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | "path/filepath" 8 | "social-network/pkg/model/response" 9 | "strings" 10 | ) 11 | 12 | func (app *App) UploadImage(w http.ResponseWriter, r *http.Request) { 13 | 14 | const maxBytes = 10 * 1024 * 1024 15 | 16 | err := r.ParseMultipartForm(maxBytes) 17 | if err != nil { 18 | app.ServeError(w, &response.Error{Cause: err.Error(), Code: 400}) 19 | return 20 | } 21 | 22 | targetPath := r.FormValue("path") 23 | fileName := r.FormValue("filename") 24 | if targetPath == "" || fileName == "" { 25 | app.ServeError(w, &response.Error{Cause: "Invalid path or filename", Code: 400}) 26 | return 27 | } 28 | 29 | file, fileHeader, err := r.FormFile("image") 30 | if err != nil { 31 | app.ServeError(w, &response.Error{Cause: err.Error(), Code: 400}) 32 | return 33 | } 34 | defer file.Close() 35 | 36 | if fileHeader.Size > maxBytes { 37 | app.ServeError(w, &response.Error{Cause: "You exeeded the allowed size", Code: 400}) 38 | return 39 | } 40 | 41 | // Extension check 42 | ext := strings.ToLower(filepath.Ext(fileName)) 43 | allowed := map[string]bool{ 44 | ".jpg": true, 45 | ".jpeg": true, 46 | ".png": true, 47 | ".gif": true, 48 | } 49 | if !allowed[ext] { 50 | app.ServeError(w, &response.Error{Cause: "Only .jpg, .jpeg, .png, .gif files are allowed", Code: 400}) 51 | return 52 | } 53 | 54 | // Create directory if it doesn't exist 55 | dirPath := filepath.Join("./static", targetPath) 56 | err = os.MkdirAll(dirPath, os.ModePerm) 57 | if err != nil { 58 | app.ServeError(w, &response.Error{Cause: err.Error(), Code: 500}) 59 | return 60 | } 61 | 62 | destPath := filepath.Join(dirPath, fileName) 63 | 64 | dst, err := os.Create(destPath) 65 | if err != nil { 66 | app.ServeError(w, &response.Error{Cause: err.Error(), Code: 500}) 67 | return 68 | } 69 | defer dst.Close() 70 | 71 | _, err = io.Copy(dst, file) 72 | if err != nil { 73 | 74 | app.ServeError(w, &response.Error{Cause: err.Error(), Code: 500}) 75 | return 76 | } 77 | 78 | w.WriteHeader(200) 79 | w.Write([]byte("File saved successfully")) 80 | } 81 | -------------------------------------------------------------------------------- /backend/pkg/repository/commentRepo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "social-network/pkg/model" 5 | "time" 6 | ) 7 | 8 | type CommentRepository struct { 9 | Repository *Repository 10 | } 11 | 12 | func (r *CommentRepository) Add(c *model.Comment) error { 13 | return r.Repository.db.QueryRow( 14 | "INSERT INTO comments (user_id, post_id, text, image) VALUES ($1, $2, $3, $4) RETURNING id", 15 | c.UserId, c.PostId, c.Text, c.Image, 16 | ).Scan(&c.ID) 17 | } 18 | 19 | func (r *CommentRepository) GetCommentsByPostId(Id int) ([]*model.Comment, error) { 20 | var comments []*model.Comment 21 | 22 | // Updated query to use firstname and lastname instead of username 23 | query := ` 24 | SELECT 25 | comments.id, 26 | comments.user_id, 27 | users.avatar, 28 | COALESCE(users.firstname || ' ' || users.lastname, '') AS author, 29 | comments.post_id, 30 | comments.text, 31 | comments.image, 32 | comments.creation_date 33 | FROM comments 34 | JOIN users ON users.id = comments.user_id 35 | WHERE post_id = $1 36 | ORDER BY comments.creation_date DESC; 37 | ` 38 | 39 | rows, err := r.Repository.db.Query(query, Id) 40 | 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | defer rows.Close() 46 | 47 | for rows.Next() { 48 | comment := &model.Comment{} 49 | if err := rows.Scan(&comment.ID, &comment.UserId, &comment.UserAvatar, &comment.Author, &comment.PostId, &comment.Text, &comment.Image, &comment.CreationDate); err != nil { 50 | return nil, err 51 | } 52 | newTime, _ := time.Parse("2006-01-02T15:04:05Z", comment.CreationDate) 53 | comment.CreationDate = newTime.Format("2006-01-02 15:04:05") 54 | comment.PostId = Id 55 | comments = append(comments, comment) 56 | } 57 | 58 | if len(comments) == 0 { 59 | return []*model.Comment{}, nil 60 | } 61 | return comments, nil 62 | } 63 | 64 | func (r *CommentRepository) GetCommentCountByPostId(postId int) (int, error) { 65 | var count int 66 | err := r.Repository.db.QueryRow( 67 | `SELECT COUNT(*) FROM comments WHERE post_id = $1`, postId, 68 | ).Scan(&count) 69 | if err != nil { 70 | return 0, err 71 | } 72 | return count, nil 73 | } 74 | -------------------------------------------------------------------------------- /frontend/helpers/uploadFile.ts: -------------------------------------------------------------------------------- 1 | import { showGlobalError } from "@/helpers/ErrorProvider"; 2 | 3 | export async function uploadFile( 4 | formData: FormData, 5 | route: string 6 | ): Promise { 7 | try { 8 | const file = formData.get("file") as File; 9 | 10 | if (!file) { 11 | const msg = "No file provided"; 12 | showGlobalError(msg); 13 | throw new Error(msg); 14 | } 15 | 16 | if (!file.type.startsWith("image/")) { 17 | const msg = "Only image files are allowed (by MIME type)"; 18 | showGlobalError(msg); 19 | throw new Error(msg); 20 | } 21 | 22 | const allowedExtensions = ["jpg", "jpeg", "png", "gif"]; 23 | const fileExtension = file.name.split(".").pop()?.toLowerCase(); 24 | 25 | if (!fileExtension || !allowedExtensions.includes(fileExtension)) { 26 | const msg = "Only .jpg, .jpeg, .png, .gif images are allowed"; 27 | showGlobalError(msg); 28 | throw new Error(msg); 29 | } 30 | 31 | const maxSizeInBytes = 10 * 1024 * 1024; 32 | if (file.size > maxSizeInBytes) { 33 | const msg = "File size must be ≤ 10MB"; 34 | showGlobalError(msg); 35 | throw new Error(msg); 36 | } 37 | 38 | const uuid = crypto.randomUUID(); 39 | const uniqueFileName = `${uuid}_${file.name 40 | .replace(/\s+/g, "-") 41 | .toLowerCase()}`; 42 | 43 | formData.append("image", file); 44 | formData.append("path", route); 45 | formData.append("filename", uniqueFileName); 46 | 47 | const response = await fetch("http://localhost:8080/uploadImage", { 48 | method: "POST", 49 | body: formData, 50 | }); 51 | 52 | if (response.ok) { 53 | return uniqueFileName; 54 | } 55 | 56 | const data = await response.json(); 57 | 58 | if (data.error) { 59 | const msg = data.error; 60 | showGlobalError(msg); 61 | throw new Error(msg); 62 | } 63 | 64 | return ""; 65 | } catch (error: any) { 66 | const msg = error?.message || "Failed to upload file"; 67 | showGlobalError(msg); 68 | console.error("Upload Error:", error); 69 | throw new Error("Failed to upload file: " + msg); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /backend/pkg/controller/profile.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "social-network/pkg/model" 5 | "social-network/pkg/model/request" 6 | "social-network/pkg/model/response" 7 | ) 8 | 9 | func (app *App) GetProfile(payload *request.RequestT) any { 10 | data, ok := payload.Data.(*request.GetProfile) 11 | if !ok { 12 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 13 | } 14 | 15 | userId := payload.Ctx.Value("user_id").(int) 16 | 17 | currentUser, err := app.repository.User().FindProfile(userId, userId) 18 | if err != nil { 19 | return &response.Error{Code: 404, Cause: err.Error()} 20 | } 21 | user, err := app.repository.User().FindProfile(data.ProfileId, userId) 22 | if err != nil { 23 | return &response.Error{Code: 404, Cause: err.Error()} 24 | } 25 | 26 | return &response.GetProfile{ 27 | User: user, 28 | CurrentUser: currentUser, 29 | } 30 | } 31 | 32 | func (app *App) GetProfiles(payload *request.RequestT) any { 33 | userId := payload.Ctx.Value("user_id").(int) 34 | 35 | followRequests, err := app.repository.Follow().GetFollowRequests(userId) 36 | if err != nil { 37 | return &response.Error{Code: 500, Cause: err.Error()} 38 | } 39 | 40 | allUsers, err := app.repository.User().GetAllUsers() 41 | if err != nil { 42 | return &response.Error{Code: 500, Cause: err.Error()} 43 | } 44 | 45 | var otherUsers []*model.User 46 | var currentUser *model.User 47 | for _, user := range allUsers { 48 | if user.ID != userId { 49 | otherUsers = append(otherUsers, user) 50 | } else { 51 | currentUser = user 52 | } 53 | } 54 | 55 | return &response.GetProfiles{ 56 | FollowRequests: followRequests, 57 | AllUsers: otherUsers, 58 | CurrentUser: currentUser, 59 | } 60 | } 61 | 62 | func (app *App) SetProfilePrivacy(payload *request.RequestT) any { 63 | data, ok := payload.Data.(*request.SetProfilePrivacy) 64 | if !ok { 65 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 66 | } 67 | 68 | userId := payload.Ctx.Value("user_id").(int) 69 | 70 | err := app.repository.User().SetUserPrivacy(userId, data.State) 71 | if err != nil { 72 | return &response.Error{Code: 500, Cause: err.Error()} 73 | } 74 | 75 | return &response.SetProfilePrivacy{ 76 | Success: true, 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /frontend/helpers/GlobalAPIHelper.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { showGlobalError } from "@/helpers/ErrorProvider"; 4 | import { useRouter } from "next/navigation"; 5 | import { useCallback } from "react"; 6 | import { closeWebSocket } from "./webSocket"; 7 | import setSessionCookie from "@/api/auth/setSession"; 8 | 9 | export const useGlobalAPIHelper = () => { 10 | const router = useRouter(); 11 | 12 | const handleAPIError = (message: string, code = 500) => { 13 | showGlobalError(message, code); 14 | 15 | if ( 16 | message.toLowerCase().startsWith("unauthorized: invalid session") && 17 | router 18 | ) { 19 | closeWebSocket(); 20 | setTimeout(() => { 21 | router.push("/auth"); 22 | }, 1000); // Delay allows popup to show 23 | } 24 | 25 | console.log(`API Error [${code}]: ${message}`); 26 | return { error: true, message }; 27 | }; 28 | 29 | const apiCall = useCallback( 30 | async (requestData: any, method: string, url: string): Promise => { 31 | try { 32 | const response = await fetch(`http://localhost:8080/${url}`, { 33 | method, 34 | headers: { 35 | "Content-Type": "application/json", 36 | }, 37 | body: JSON.stringify(requestData), 38 | credentials: "include", 39 | }); 40 | 41 | const data = await response.json(); 42 | 43 | if ( 44 | (url === "login" || url === "register") && 45 | data.data?.session && 46 | !data.error 47 | ) { 48 | await setSessionCookie(data.data?.session); 49 | data.data.session = "true"; 50 | } 51 | 52 | if (!response.ok) { 53 | const message = 54 | data?.error?.cause || data?.message || "Unknown error from server"; 55 | const code = data?.error?.code || response.status; 56 | return handleAPIError(message, code); 57 | } 58 | 59 | if (data.error) { 60 | const message = data.error.cause || "Unknown error"; 61 | const code = data.error.code || 500; 62 | return handleAPIError(message, code); 63 | } 64 | 65 | return data.data ?? data; 66 | } catch (err: any) { 67 | console.error("API call failed:", err); 68 | return handleAPIError(err.message || "Unexpected error", 500); 69 | } 70 | }, 71 | [] 72 | ); 73 | 74 | return { apiCall }; 75 | }; 76 | -------------------------------------------------------------------------------- /frontend/public/icons/broken-heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 17 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /frontend/helpers/webSocket.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import getToken from "@/api/auth/getToken"; 4 | import { showGlobalError } from "@/helpers/ErrorProvider"; 5 | 6 | export let socket: WebSocket | null = null; 7 | 8 | let listeners: { [key: string]: ((data: any) => void)[] } = ({} = {}); 9 | 10 | export const connectWebSocket = async (): Promise => { 11 | if (socket) return socket; 12 | 13 | try { 14 | const token = await getToken(); 15 | socket = new WebSocket(`ws://localhost:8080/ws?session=${token}`); 16 | 17 | socket.onopen = () => { 18 | console.log("✅ WebSocket connected"); 19 | }; 20 | 21 | socket.onmessage = (event) => { 22 | try { 23 | const data = JSON.parse(event.data); 24 | console.log("WebSocket message received:", data); 25 | 26 | if (data.error) { 27 | showGlobalError( 28 | data.error.cause || "Unknown WebSocket error", 29 | data.error.code || 500 30 | ); 31 | console.warn("WebSocket error:", data.error); 32 | return; 33 | } 34 | 35 | const type = data.data?.type || data?.type; 36 | 37 | if (type && listeners[type]) { 38 | listeners[type].forEach((callback) => callback(data.data)); 39 | } 40 | } catch (error) { 41 | console.error("Error parsing WebSocket message:", error); 42 | } 43 | }; 44 | 45 | socket.onerror = (err) => { 46 | console.error("WebSocket error:", err); 47 | }; 48 | 49 | return socket; 50 | } catch (error) { 51 | return null; 52 | } 53 | }; 54 | 55 | export const closeWebSocket = (): void => { 56 | if (socket && socket.readyState === WebSocket.OPEN) { 57 | socket.close(); 58 | socket = null; 59 | listeners = {} = {}; 60 | console.log("🔌 WebSocket connection closed"); 61 | } 62 | }; 63 | 64 | // Register a callback for a given message type 65 | export const onMessageType = ( 66 | type: string, 67 | callback: (data: any) => void 68 | ): (() => void) => { 69 | if (!listeners[type]) { 70 | listeners[type] = []; 71 | } 72 | 73 | listeners[type].push(callback); 74 | 75 | // Return an unsubscribe function 76 | return () => { 77 | listeners[type] = listeners[type]?.filter((cb) => cb !== callback); 78 | 79 | // Clean up if no more listeners 80 | if (listeners[type]?.length === 0) { 81 | delete listeners[type]; 82 | } 83 | }; 84 | }; 85 | -------------------------------------------------------------------------------- /backend/pkg/controller/groupReaction.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "log" 5 | "social-network/pkg/model" 6 | "social-network/pkg/model/request" 7 | "social-network/pkg/model/response" 8 | ) 9 | 10 | func (app *App) ReactToGroupPost(payload *request.RequestT) any { 11 | data, ok := payload.Data.(*request.ReactToGroupPost) 12 | if !ok { 13 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 14 | } 15 | 16 | userId := payload.Ctx.Value("user_id").(int) 17 | 18 | isMember, err := app.repository.Group().IsGroupPostMember(userId, data.PostId) 19 | if err != nil || !isMember { 20 | return &response.Error{Code: 403, Cause: "You are not a member of this group"} 21 | } 22 | 23 | err = app.repository.Group().UpsertGroupReaction(userId, data.PostId, data.Reaction) 24 | if err != nil { 25 | log.Println("Error saving group reaction:", err) 26 | return &response.Error{Code: 500, Cause: "Error saving reaction: " + err.Error()} 27 | } 28 | 29 | counts, err := app.repository.Group().GetGroupReactionCounts(data.PostId) 30 | if err != nil { 31 | log.Println("Error getting group reaction counts:", err) 32 | return &response.Error{Code: 500, Cause: "Error getting reaction counts: " + err.Error()} 33 | } 34 | 35 | userReaction, err := app.repository.Group().GetGroupUserReaction(userId, data.PostId) 36 | if err != nil { 37 | log.Println("Error getting group user reaction:", err) 38 | return &response.Error{Code: 500, Cause: "Error getting user reaction: " + err.Error()} 39 | } 40 | counts.UserReaction = userReaction 41 | 42 | post := &model.Post{ 43 | ID: data.PostId, 44 | Reactions: counts, 45 | } 46 | 47 | return &response.ReactToGroupPost{ 48 | Post: post, 49 | } 50 | } 51 | 52 | func (app *App) GetGroupPost(payload *request.RequestT) any { 53 | data, ok := payload.Data.(*request.GetGroupPost) 54 | if !ok { 55 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 56 | } 57 | userId := payload.Ctx.Value("user_id").(int) 58 | 59 | isMember, err := app.repository.Group().IsGroupPostMember(userId, data.PostId) 60 | if err != nil || !isMember { 61 | return &response.Error{Code: 403, Cause: "You are not a member of this group"} 62 | } 63 | 64 | post, err := app.repository.Group().GetGroupPostById(userId, data.PostId) 65 | if err != nil { 66 | log.Println("Error getting group post data:", err) 67 | return &response.Error{Code: 500, Cause: "Error getting post data: " + err.Error()} 68 | } 69 | 70 | return &response.GetGroupPost{ 71 | Post: post, 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /frontend/public/icons/users.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /backend/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs= 5 | github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= 6 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 7 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 8 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 9 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 10 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 11 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 12 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 13 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 14 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 15 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 16 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 17 | github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= 18 | github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 19 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 20 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 21 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 22 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 23 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 24 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 25 | go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= 26 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 27 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 28 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 29 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 30 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 31 | -------------------------------------------------------------------------------- /backend/pkg/controller/comment.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "log" 5 | "social-network/pkg/model" 6 | "social-network/pkg/model/request" 7 | "social-network/pkg/model/response" 8 | ) 9 | 10 | func (app *App) AddComment(payload *request.RequestT) any { 11 | data, ok := payload.Data.(*request.AddComment) 12 | if !ok { 13 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 14 | } 15 | 16 | userId := payload.Ctx.Value("user_id").(int) 17 | 18 | if data.Text == "" && data.Image == "" { 19 | return &response.Error{Code: 400, Cause: "Comment text cannot be empty"} 20 | } 21 | if len(data.Text) > 500 { 22 | return &response.Error{Code: 400, Cause: "Comment text exceeds maximum length of 500 characters"} 23 | } 24 | 25 | user, err := app.repository.User().Find(userId) 26 | if err != nil { 27 | return &response.Error{Code: 500, Cause: "Error finding user"} 28 | } 29 | 30 | comment := &model.Comment{ 31 | UserId: userId, 32 | PostId: data.PostId, 33 | Text: data.Text, 34 | Image: data.Image, 35 | Author: user.Username, 36 | } 37 | 38 | err = app.repository.Comment().Add(comment) 39 | if err != nil { 40 | log.Println("Error adding comment:", err) 41 | return &response.Error{Code: 500, Cause: "Error adding comment: " + err.Error()} 42 | } 43 | 44 | comments, err := app.repository.Comment().GetCommentsByPostId(data.PostId) 45 | if err != nil { 46 | log.Println("Error getting comments:", err) 47 | return &response.Error{Code: 500, Cause: "Error getting comments: " + err.Error()} 48 | } 49 | 50 | post, err := app.repository.Post().GetPostById(userId, data.PostId) 51 | if err != nil { 52 | log.Println("Error getting post data:", err) 53 | return &response.Error{Code: 500, Cause: "Error getting post data: " + err.Error()} 54 | } 55 | 56 | post.Comment = comments 57 | return post 58 | } 59 | 60 | func (app *App) GetComments(payload *request.RequestT) any { 61 | data, ok := payload.Data.(*request.GetComments) 62 | if !ok { 63 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 64 | } 65 | 66 | userId := payload.Ctx.Value("user_id").(int) 67 | 68 | comments, err := app.repository.Comment().GetCommentsByPostId(data.PostId) 69 | if err != nil { 70 | log.Println("Error getting comments:", err) 71 | return &response.Error{Code: 500, Cause: "Error getting comments: " + err.Error()} 72 | } 73 | 74 | post, err := app.repository.Post().GetPostById(userId, data.PostId) 75 | if err != nil { 76 | log.Println("Error getting post data:", err) 77 | return &response.Error{Code: 500, Cause: "Error getting post data: " + err.Error()} 78 | } 79 | 80 | post.Comment = comments 81 | return post 82 | } 83 | -------------------------------------------------------------------------------- /backend/pkg/controller/chat.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "social-network/pkg/model" 5 | "social-network/pkg/model/request" 6 | "social-network/pkg/model/response" 7 | ) 8 | 9 | func (app *App) GetChat(payload *request.RequestT) any { 10 | userId := payload.Ctx.Value("user_id").(int) 11 | 12 | privateConvs, err := app.repository.Message().GetPrivateConversations(userId) 13 | if err != nil { 14 | return &response.Error{Code: 500, Cause: err.Error()} 15 | } 16 | 17 | groupConvs, err := app.repository.Message().GetGroupConversations(userId) 18 | if err != nil { 19 | return &response.Error{Code: 500, Cause: err.Error()} 20 | } 21 | 22 | newConvs, err := app.repository.Message().GetNewConversations(userId) 23 | if err != nil { 24 | return &response.Error{Code: 500, Cause: err.Error()} 25 | } 26 | 27 | return &response.GetChat{ 28 | PrivateConvs: privateConvs, 29 | GroupConvs: groupConvs, 30 | NewConvs: newConvs, 31 | } 32 | } 33 | 34 | func (app *App) GetMessages(payload *request.RequestT) any { 35 | data, ok := payload.Data.(*request.GetMessages) 36 | if !ok { 37 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 38 | } 39 | userId := payload.Ctx.Value("user_id").(int) 40 | 41 | m := &model.Message{} 42 | if data.IsGroup { 43 | m.GroupId = data.Id 44 | } else { 45 | m.RecipientId = data.Id 46 | } 47 | m.SenderId = userId 48 | 49 | messages, err := app.repository.Message().GetMessages(m) 50 | if err != nil { 51 | return &response.Error{Code: 500, Cause: err.Error()} 52 | } 53 | 54 | app.UpdateSeenMessage(data.IsGroup, userId, data.Id) 55 | wsMsg := map[string]any{ 56 | "type": "unreadmsgRequestHandled", 57 | } 58 | for _, c := range app.clients[userId] { 59 | app.ShowMessage(c, wsMsg) 60 | } 61 | 62 | return &response.GetMessages{ 63 | Messages: messages, 64 | } 65 | } 66 | 67 | func (app *App) AddMessage(payload *request.RequestT) (*response.AddMessage, *response.Error) { 68 | data, ok := payload.Data.(*request.AddMessage) 69 | if !ok { 70 | return nil, &response.Error{Code: 400, Cause: "Invalid payload type"} 71 | } 72 | userId := payload.Ctx.Value("user_id").(int) 73 | 74 | if len(data.Message) == 0 || len(data.Message) > 1000 || data.Id < 1 { 75 | return nil, &response.Error{Code: 400, Cause: "Invalid message input"} 76 | } 77 | 78 | m := &model.Message{} 79 | if data.IsGroup { 80 | m.GroupId = data.Id 81 | } else { 82 | m.RecipientId = data.Id 83 | } 84 | m.SenderId = userId 85 | m.Text = data.Message 86 | 87 | // Should add middleware to validate if user can send message 88 | 89 | err := app.repository.Message().Add(m) 90 | if err != nil { 91 | return nil, &response.Error{Code: 500, Cause: err.Error()} 92 | } 93 | 94 | err = app.repository.Message().AddGroupMessageNotifications(m) 95 | if err != nil { 96 | return nil, &response.Error{Code: 500, Cause: err.Error()} 97 | } 98 | 99 | return &response.AddMessage{ 100 | Type: "addMessage", 101 | Message: m, 102 | IsGroup: data.IsGroup, 103 | }, nil 104 | } 105 | -------------------------------------------------------------------------------- /backend/pkg/server/start.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "social-network/pkg/db/sqlite" 7 | ) 8 | 9 | func Start() error { 10 | db := sqlite.InitDB("pkg/db/forum.db") 11 | 12 | defer db.Close() 13 | 14 | s := NewServer(http.NewServeMux(), db) 15 | 16 | // Auth 17 | s.AddRoute("/login", s.app.Login) 18 | s.AddRoute("/register", s.app.Register) 19 | s.AddRoute("/logout", s.app.Logout) 20 | 21 | // Posts 22 | s.AddRoute("/getPosts", s.app.GetPosts) 23 | s.AddRoute("/getPost", s.app.GetPostData) 24 | s.AddRoute("/addPost", s.app.AddPost) 25 | s.AddRoute("/reactToPost", s.app.ReactToPost) 26 | 27 | // Post Shares 28 | s.AddRoute("/getPostShares", s.app.GetPostShares) 29 | s.AddRoute("/addPostShare", s.app.AddPostShare) 30 | s.AddRoute("/removePostShare", s.app.RemovePostShare) 31 | 32 | // Comments 33 | s.AddRoute("/addComment", s.app.AddComment) 34 | s.AddRoute("/getComments", s.app.GetComments) 35 | 36 | // Profiles 37 | s.AddRoute("/getProfiles", s.app.GetProfiles) 38 | s.AddRoute("/getProfile", s.app.GetProfile) 39 | s.AddRoute("/setPrivacy", s.app.SetProfilePrivacy) 40 | 41 | // Follows 42 | s.AddRoute("/requestFollow", s.app.RequestFollow) 43 | s.AddRoute("/acceptFollow", s.app.AcceptFollow) 44 | s.AddRoute("/deleteFollow", s.app.DeleteFollow) 45 | 46 | // Groups 47 | s.AddRoute("/createGroup", s.app.CreateGroup) 48 | s.AddRoute("/getGroups", s.app.GetGroups) 49 | s.AddRoute("/getGroup", s.app.GetGroupData) 50 | 51 | // Group post reactions and comments 52 | s.AddRoute("/reactToGroupPost", s.app.ReactToGroupPost, s.app.IsMemberMiddleware) 53 | s.AddRoute("/addGroupComment", s.app.AddGroupComment, s.app.IsMemberMiddleware) 54 | s.AddRoute("/getGroupComments", s.app.GetGroupComments, s.app.IsMemberMiddleware) 55 | 56 | s.AddRoute("/addGroupPost", s.app.AddGroupPost, s.app.IsMemberMiddleware) 57 | s.AddRoute("/addGroupEvent", s.app.AddGroupEvent, s.app.IsMemberMiddleware) 58 | s.AddRoute("/addEventOption", s.app.AddEventOption, s.app.IsMemberMiddleware) 59 | s.AddRoute("/requestJoinGroup", s.app.RequestJoinGroup) 60 | s.AddRoute("/respondToJoinRequest", s.app.RespondToJoinRequest) 61 | 62 | // Group Invitation Routes 63 | s.AddRoute("/getGroupInviteUsers", s.app.GetGroupInviteUsers, s.app.IsMemberMiddleware) 64 | s.AddRoute("/inviteUserToGroup", s.app.InviteUserToGroup, s.app.IsMemberMiddleware) 65 | s.AddRoute("/respondToGroupInvitation", s.app.RespondToGroupInvitation) 66 | 67 | // Chat 68 | s.AddRoute("/getChatData", s.app.GetChat) 69 | s.AddRoute("/getMessages", s.app.GetMessages) 70 | 71 | //notif 72 | s.AddRoute("/getAllNotifications", s.app.GetAllNotifications) 73 | s.AddRoute("/getNewFollowNotification", s.app.CheckNewFollowNotification) 74 | s.AddRoute("/deleteFollowNotif", s.app.DeleteFollowNotification) 75 | s.AddRoute("/deleteNotifNewEvent", s.app.DeleteNewEventNotification) 76 | 77 | // ws 78 | s.router.HandleFunc("/ws", s.app.WebSocketHandler) 79 | 80 | // Image 81 | s.router.HandleFunc("/uploadImage", s.app.UploadImage) 82 | s.router.Handle("/getProtectedImage", s.app.ImageMiddleware(http.HandlerFunc(s.app.ProtectedImageHandler))) 83 | 84 | log.Println("Server started at http://localhost:8080/") 85 | return http.ListenAndServe(":8080", s.app.CorsMiddleware(s)) 86 | } 87 | -------------------------------------------------------------------------------- /backend/pkg/controller/follow.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "log" 5 | "social-network/pkg/model/request" 6 | "social-network/pkg/model/response" 7 | "time" 8 | ) 9 | 10 | func (app *App) RequestFollow(payload *request.RequestT) any { 11 | data, ok := payload.Data.(*request.RequestFollow) 12 | if !ok { 13 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 14 | } 15 | userId := payload.Ctx.Value("user_id").(int) 16 | 17 | err := app.repository.Follow().RequestFollow(data.ProfileId, userId) 18 | if err != nil { 19 | log.Println("Error requesting follow:", err) 20 | return &response.Error{Code: 500, Cause: err.Error()} 21 | } 22 | notification := map[string]any{ 23 | "type": "notifications", 24 | "followerId": userId, 25 | "message": "New follow request", 26 | "timestamp": time.Now().Unix(), 27 | } 28 | app.sendNotificationToUser(data.ProfileId, notification) 29 | 30 | return &response.RequestFollow{ 31 | Success: true, 32 | Message: "Follow request sent", 33 | } 34 | } 35 | 36 | func (app *App) AcceptFollow(payload *request.RequestT) any { 37 | data, ok := payload.Data.(*request.AcceptFollow) 38 | if !ok { 39 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 40 | } 41 | userId := payload.Ctx.Value("user_id").(int) 42 | 43 | err := app.repository.Follow().AcceptFollow(data.ProfileId, userId) 44 | if err != nil { 45 | log.Println("Error accepting follow:", err) 46 | return &response.Error{Code: 500, Cause: err.Error()} 47 | } 48 | wsMsg := map[string]any{ 49 | "type": "followRequestHandled", 50 | } 51 | for _, c := range app.clients[userId] { 52 | app.ShowMessage(c, wsMsg) 53 | } 54 | return &response.AcceptFollow{ 55 | Success: true, 56 | Message: "Follow request accepted", 57 | } 58 | } 59 | 60 | func (app *App) DeleteFollow(payload *request.RequestT) any { 61 | data, ok := payload.Data.(*request.DeleteFollow) 62 | if !ok { 63 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 64 | } 65 | userId := payload.Ctx.Value("user_id").(int) 66 | 67 | var err error 68 | if data.IsFollower { 69 | err = app.repository.Follow().DeleteFollow(data.ProfileId, userId) 70 | if err == nil { 71 | err = app.repository.Follow().DeleteNotif(userId, data.ProfileId) 72 | } 73 | notification := map[string]any{ 74 | "type": "notifications", 75 | "followerId": userId, 76 | "message": "unfollow", 77 | "timestamp": time.Now().Unix(), 78 | } 79 | app.sendNotificationToUser(data.ProfileId, notification) 80 | } else { 81 | err = app.repository.Follow().DeleteFollow(userId, data.ProfileId) 82 | notification := map[string]any{ 83 | "type": "notifications", 84 | "followerId": data.ProfileId, 85 | "message": "unfollow", 86 | "timestamp": time.Now().Unix(), 87 | } 88 | app.sendNotificationToUser(userId, notification) 89 | } 90 | if err != nil { 91 | log.Println("Error deleting follow:", err) 92 | return &response.Error{Code: 500, Cause: err.Error()} 93 | } 94 | 95 | return &response.DeleteFollow{ 96 | Success: true, 97 | Message: "Unfollowed successfully", 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /backend/pkg/controller/post.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "social-network/pkg/model" 8 | "social-network/pkg/model/request" 9 | "social-network/pkg/model/response" 10 | ) 11 | 12 | func (app *App) GetPosts(payload *request.RequestT) any { 13 | data, ok := payload.Data.(*request.GetPosts) 14 | if !ok { 15 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 16 | } 17 | 18 | userId := payload.Ctx.Value("user_id").(int) 19 | 20 | posts, err := app.repository.Post().GetPosts(userId, data.StartId) 21 | if err != nil { 22 | log.Println("Error in getting feed data:", err) 23 | return &response.Error{Code: 400, Cause: "Error in getting feed data"} 24 | } 25 | 26 | return &response.GetPosts{ 27 | Posts: posts, 28 | Userid: userId, 29 | } 30 | } 31 | 32 | func (app *App) GetPostData(payload *request.RequestT) any { 33 | data, ok := payload.Data.(*request.GetPost) 34 | if !ok { 35 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 36 | } 37 | 38 | userId := payload.Ctx.Value("user_id").(int) 39 | post, err := app.repository.Post().GetPostById(userId, data.PostId) 40 | 41 | if err != nil { 42 | if err == sql.ErrNoRows { 43 | return &response.Error{Code: 404, Cause: "Post not found"} 44 | } 45 | return &response.Error{Code: 400, Cause: "Error in getting post data"} 46 | } 47 | 48 | count, err := app.repository.Comment().GetCommentCountByPostId(data.PostId) 49 | if err != nil { 50 | log.Println("Error getting comment count:", err) 51 | } 52 | post.CommentCount = count 53 | fmt.Println("post", post) 54 | 55 | return &response.GetPost{ 56 | Userid: userId, 57 | Post: post, 58 | } 59 | } 60 | 61 | func (app *App) AddPost(payload *request.RequestT) any { 62 | fmt.Println(payload) 63 | data, ok := payload.Data.(*request.AddPost) 64 | if !ok { 65 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 66 | } 67 | 68 | if data.Caption == "" && data.Image == "" { 69 | return &response.Error{Code: 400, Cause: "Can't create empty posts"} 70 | } 71 | 72 | if data.Privacy != "public" && data.Privacy != "almost-private" && data.Privacy != "private" { 73 | return &response.Error{Code: 400, Cause: "Invalid privacy type"} 74 | } 75 | 76 | user, err := app.repository.User().Find(payload.Ctx.Value("user_id").(int)) 77 | if err != nil { 78 | return &response.Error{Code: 500, Cause: "An error has aquired while finding user"} 79 | } 80 | 81 | post := &model.Post{ 82 | UserId: user.ID, 83 | Caption: data.Caption, 84 | Privacy: data.Privacy, 85 | Image: data.Image, 86 | User: &model.User{ 87 | Firstname: user.Firstname, 88 | Lastname: user.Lastname, 89 | Avatar: user.Avatar, 90 | }, 91 | } 92 | 93 | if len(post.Caption) > 1000 { 94 | return &response.Error{Code: 400, Cause: "Caption exceeds maximum allowed length"} 95 | } 96 | 97 | err = app.repository.Post().Add(post) 98 | if err != nil { 99 | log.Println("Error adding post:", err) 100 | return &response.Error{Code: 500, Cause: "Error adding post: " + err.Error()} 101 | } 102 | 103 | return &response.AddPost{ 104 | Post: post, 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /backend/pkg/model/request/request.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "social-network/pkg/model" 9 | ) 10 | 11 | type Payload struct { 12 | Type string `json:"type"` 13 | Data json.RawMessage 14 | } 15 | 16 | type RequestT struct { 17 | Data any 18 | Ctx context.Context 19 | Middlewares []func(http.Handler, *RequestT) http.Handler 20 | } 21 | 22 | var requestTypes = map[string]any{ 23 | model.TYPE_REGISTER: &Register{}, 24 | model.TYPE_LOGIN: &Login{}, 25 | model.Type_GET_POSTS: &GetPosts{}, 26 | model.Type_GET_POST: &GetPost{}, 27 | model.Type_ADD_POST: &AddPost{}, 28 | model.Type_REACT_TO_POST: &ReactToPost{}, 29 | model.Type_GET_POST_SHARES: &GetPostShares{}, 30 | model.Type_ADD_POST_SHARE: &AddPostShare{}, 31 | model.Type_REMOVE_POST_SHARE: &RemovePostShare{}, 32 | model.Type_ADD_COMMENT: &AddComment{}, 33 | model.Type_GET_COMMENTS: &GetComments{}, 34 | model.Type_GET_PROFILE: &GetProfile{}, 35 | model.Type_GET_PROFILES: nil, 36 | model.Type_SET_PROFILE_PRIVACY: &SetProfilePrivacy{}, 37 | "create-group": &CreateGroup{}, 38 | "get-groups": nil, 39 | "get-group-data": &GetGroupData{}, 40 | "add-group-post": &AddGroupPost{}, 41 | "add-group-event": &AddGroupEvent{}, 42 | "invite-user-to-group": &InviteUserToGroup{}, 43 | "respond-to-group-invitation": &RespondToGroupInvitation{}, 44 | "get-group-invite-users": &GetGroupInviteUsers{}, 45 | "add-event-option": &AddEventOption{}, 46 | "request-join-group": &RequestJoinGroup{}, 47 | "respond-to-join-request": &RespondToJoinRequest{}, 48 | "get-join-request-count": nil, 49 | "get-unread-messages-count": nil, 50 | "add-group-comment": &AddGroupComment{}, 51 | "get-group-comments": &GetGroupComments{}, 52 | "react-to-group-post": &ReactToGroupPost{}, 53 | "get-group-post": &GetGroupPost{}, 54 | "request-follow": &RequestFollow{}, 55 | "accept-follow": &AcceptFollow{}, 56 | "delete-follow": &DeleteFollow{}, 57 | "get-chat": nil, 58 | "get-messages": &GetMessages{}, 59 | "add-message": &AddMessage{}, 60 | "get-all-notifications": nil, 61 | "check-new-follow-notification": nil, 62 | "delete-follow-notification": &DeleteFollowNotification{}, 63 | "delete-new-event-notification": &DeleteNewEventNotification{}, 64 | "logout": nil, 65 | "update-seen-message": &UpdateSeenMessageWS{}, 66 | } 67 | 68 | func (r Payload) Decode() (string, *RequestT, error) { 69 | instance, exist := requestTypes[r.Type] 70 | if !exist { 71 | return "", nil, fmt.Errorf("invalid request type %s", r.Type) 72 | } 73 | 74 | if instance != nil { 75 | err := json.Unmarshal(r.Data, instance) 76 | if err != nil { 77 | return "", nil, err 78 | } 79 | } 80 | 81 | request := &RequestT{ 82 | Data: instance, 83 | } 84 | 85 | return r.Type, request, nil 86 | } 87 | 88 | func Unmarshal(data []byte) (string, *RequestT, error) { 89 | request := Payload{} 90 | err := json.Unmarshal(data, &request) 91 | if err != nil { 92 | return "", nil, err 93 | } 94 | return request.Decode() 95 | } 96 | -------------------------------------------------------------------------------- /backend/pkg/controller/groupComment.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "log" 5 | "social-network/pkg/model" 6 | "social-network/pkg/model/request" 7 | "social-network/pkg/model/response" 8 | ) 9 | 10 | func (app *App) AddGroupComment(payload *request.RequestT) any { 11 | data, ok := payload.Data.(*request.AddGroupComment) 12 | if !ok { 13 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 14 | } 15 | userId := payload.Ctx.Value("user_id").(int) 16 | 17 | if data.Text == "" && data.Image == "" { 18 | return &response.Error{Code: 400, Cause: "Comment text cannot be empty"} 19 | } 20 | if len(data.Text) > 500 { 21 | return &response.Error{Code: 400, Cause: "Comment text exceeds maximum length of 500 characters"} 22 | } 23 | 24 | isMember, err := app.repository.Group().IsGroupPostMember(userId, data.PostId) 25 | if err != nil || !isMember { 26 | return &response.Error{Code: 403, Cause: "You are not a member of this group"} 27 | } 28 | 29 | user, err := app.repository.User().Find(userId) 30 | if err != nil { 31 | return &response.Error{Code: 500, Cause: "Error finding user"} 32 | } 33 | 34 | comment := &model.Comment{ 35 | UserId: userId, 36 | PostId: data.PostId, 37 | Text: data.Text, 38 | Image: data.Image, 39 | Author: user.Username, 40 | } 41 | 42 | if len(comment.Text) > 10000 { 43 | log.Println("Error adding group comment:") 44 | return &response.Error{Code: 400, Cause: "Comment cannot be too large"} 45 | } 46 | 47 | err = app.repository.Group().AddGroupComment(comment) 48 | if err != nil { 49 | log.Println("Error adding group comment:", err) 50 | return &response.Error{Code: 500, Cause: "Error adding comment: " + err.Error()} 51 | } 52 | 53 | comments, err := app.repository.Group().GetGroupCommentsByPostId(data.PostId) 54 | if err != nil { 55 | log.Println("Error getting group comments:", err) 56 | return &response.Error{Code: 500, Cause: "Error getting comments: " + err.Error()} 57 | } 58 | 59 | post, err := app.repository.Group().GetGroupPostById(userId, data.PostId) 60 | if err != nil { 61 | log.Println("Error getting group post data:", err) 62 | return &response.Error{Code: 500, Cause: "Error getting post data: " + err.Error()} 63 | } 64 | 65 | post.Comment = comments 66 | return &response.AddGroupComment{ 67 | Post: post, 68 | } 69 | } 70 | 71 | func (app *App) GetGroupComments(payload *request.RequestT) any { 72 | data, ok := payload.Data.(*request.GetGroupComments) 73 | if !ok { 74 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 75 | } 76 | userId := payload.Ctx.Value("user_id").(int) 77 | 78 | isMember, err := app.repository.Group().IsGroupPostMember(userId, data.PostId) 79 | if err != nil || !isMember { 80 | return &response.Error{Code: 403, Cause: "You are not a member of this group"} 81 | } 82 | 83 | comments, err := app.repository.Group().GetGroupCommentsByPostId(data.PostId) 84 | if err != nil { 85 | log.Println("Error getting group comments:", err) 86 | return &response.Error{Code: 500, Cause: "Error getting comments: " + err.Error()} 87 | } 88 | 89 | post, err := app.repository.Group().GetGroupPostById(userId, data.PostId) 90 | if err != nil { 91 | log.Println("Error getting group post data:", err) 92 | return &response.Error{Code: 500, Cause: "Error getting post data: " + err.Error()} 93 | } 94 | 95 | post.Comment = comments 96 | return &response.GetGroupComments{ 97 | Post: post, 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /backend/pkg/repository/groupCommentRepo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "social-network/pkg/model" 5 | "time" 6 | ) 7 | 8 | func (r *GroupRepository) AddGroupComment(c *model.Comment) error { 9 | return r.Repository.db.QueryRow( 10 | "INSERT INTO group_comments (user_id, post_id, text, image) VALUES ($1, $2, $3, $4) RETURNING id", 11 | c.UserId, c.PostId, c.Text, c.Image, 12 | ).Scan(&c.ID) 13 | } 14 | 15 | func (r *GroupRepository) GetGroupCommentsByPostId(postId int) ([]*model.Comment, error) { 16 | var comments []*model.Comment 17 | 18 | query := ` 19 | SELECT 20 | gc.id, 21 | gc.user_id, 22 | users.avatar, 23 | COALESCE(users.firstname || ' ' || users.lastname, '') AS author, 24 | gc.post_id, 25 | gc.text, 26 | gc.image, 27 | gc.creation_date 28 | FROM group_comments gc 29 | JOIN users ON users.id = gc.user_id 30 | WHERE gc.post_id = $1 31 | ORDER BY gc.creation_date DESC; 32 | ` 33 | 34 | rows, err := r.Repository.db.Query(query, postId) 35 | if err != nil { 36 | return nil, err 37 | } 38 | defer rows.Close() 39 | 40 | for rows.Next() { 41 | comment := &model.Comment{} 42 | if err := rows.Scan(&comment.ID, &comment.UserId, &comment.UserAvatar, &comment.Author, &comment.PostId, &comment.Text, &comment.Image, &comment.CreationDate); err != nil { 43 | return nil, err 44 | } 45 | newTime, _ := time.Parse("2006-01-02T15:04:05Z", comment.CreationDate) 46 | comment.CreationDate = newTime.Format("2006-01-02 15:04:05") 47 | comment.PostId = postId 48 | comments = append(comments, comment) 49 | } 50 | 51 | if len(comments) == 0 { 52 | return []*model.Comment{}, nil 53 | } 54 | return comments, nil 55 | } 56 | 57 | func (r *GroupRepository) IsGroupPostMember(userId, postId int) (bool, error) { 58 | var isMember bool 59 | query := ` 60 | SELECT EXISTS( 61 | SELECT 1 62 | FROM group_posts gp 63 | JOIN group_members gm ON gm.group_id = gp.group_id 64 | WHERE gp.id = $1 AND gm.user_id = $2 AND gm.is_accepted = TRUE 65 | )` 66 | err := r.Repository.db.QueryRow(query, postId, userId).Scan(&isMember) 67 | return isMember, err 68 | } 69 | 70 | func (r *GroupRepository) GetGroupPostById(userId, postId int) (*model.Post, error) { 71 | post := &model.Post{} 72 | user := &model.User{} 73 | 74 | query := ` 75 | SELECT 76 | gp.id, gp.user_id, gp.caption, gp.image, gp.creation_date, 77 | u.firstname, u.lastname, u.avatar 78 | FROM group_posts gp 79 | JOIN users u ON u.id = gp.user_id 80 | WHERE gp.id = $1 81 | ` 82 | 83 | err := r.Repository.db.QueryRow(query, postId).Scan( 84 | &post.ID, &post.UserId, &post.Caption, &post.Image, &post.CreationDate, 85 | &user.Firstname, &user.Lastname, &user.Avatar, 86 | ) 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | post.User = user 92 | 93 | // Get reaction counts 94 | reactions, err := r.GetGroupReactionCounts(postId) 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | // Get user's reaction 100 | userReaction, err := r.GetGroupUserReaction(userId, postId) 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | reactions.UserReaction = userReaction 106 | post.Reactions = reactions 107 | 108 | return post, nil 109 | } 110 | 111 | func (r *GroupRepository) GetGroupCommentCountByPostId(postId int) (int, error) { 112 | var count int 113 | err := r.Repository.db.QueryRow(` 114 | SELECT COUNT(*) 115 | FROM group_comments 116 | WHERE post_id = $1; 117 | `, postId).Scan(&count) 118 | 119 | if err != nil { 120 | return 0, err 121 | } 122 | return count, nil 123 | } 124 | -------------------------------------------------------------------------------- /backend/pkg/repository/postShareRepo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "database/sql" 5 | "social-network/pkg/model" 6 | ) 7 | 8 | type PostShareRepository struct { 9 | Repository *Repository 10 | } 11 | 12 | // GetPostShares returns all users who have access to a specific private post 13 | func (r *PostShareRepository) GetPostShares(postId int) ([]*model.User, error) { 14 | var users []*model.User 15 | query := `SELECT u.id, u.firstname, u.lastname, u.nickname, u.avatar 16 | FROM post_shares ps 17 | JOIN users u ON ps.shared_with_user_id = u.id 18 | WHERE ps.post_id = $1 19 | ORDER BY u.firstname, u.lastname` 20 | 21 | rows, err := r.Repository.db.Query(query, postId) 22 | if err != nil { 23 | return nil, err 24 | } 25 | defer rows.Close() 26 | 27 | for rows.Next() { 28 | user := &model.User{} 29 | if err := rows.Scan(&user.ID, &user.Firstname, &user.Lastname, &user.Nickname, &user.Avatar); err != nil { 30 | return nil, err 31 | } 32 | users = append(users, user) 33 | } 34 | 35 | if err := rows.Err(); err != nil { 36 | return nil, err 37 | } 38 | 39 | return users, nil 40 | } 41 | 42 | // GetAvailableUsersToShare returns followers who don't already have access to the private post 43 | func (r *PostShareRepository) GetAvailableUsersToShare(postId, postOwnerId int) ([]*model.User, error) { 44 | var users []*model.User 45 | query := `SELECT u.id, u.firstname, u.lastname, u.nickname, u.avatar 46 | FROM users u 47 | JOIN followers f ON u.id = f.follower_id 48 | WHERE f.following_id = $1 49 | AND f.is_accepted = TRUE 50 | AND u.id NOT IN ( 51 | SELECT ps.shared_with_user_id 52 | FROM post_shares ps 53 | WHERE ps.post_id = $2 54 | ) 55 | ORDER BY u.firstname, u.lastname` 56 | 57 | rows, err := r.Repository.db.Query(query, postOwnerId, postId) 58 | if err != nil { 59 | return nil, err 60 | } 61 | defer rows.Close() 62 | 63 | for rows.Next() { 64 | user := &model.User{} 65 | if err := rows.Scan(&user.ID, &user.Firstname, &user.Lastname, &user.Nickname, &user.Avatar); err != nil { 66 | return nil, err 67 | } 68 | users = append(users, user) 69 | } 70 | 71 | if err := rows.Err(); err != nil { 72 | return nil, err 73 | } 74 | 75 | return users, nil 76 | } 77 | 78 | // AddPostShare adds a user to the post shares 79 | func (r *PostShareRepository) AddPostShare(postId, userId int) error { 80 | // Check if share already exists 81 | var existingId int 82 | checkQuery := `SELECT id FROM post_shares WHERE post_id = $1 AND shared_with_user_id = $2` 83 | err := r.Repository.db.QueryRow(checkQuery, postId, userId).Scan(&existingId) 84 | 85 | if err == nil { 86 | return nil // Share already exists 87 | } 88 | 89 | if err != sql.ErrNoRows { 90 | return err 91 | } 92 | 93 | // Add new share 94 | query := `INSERT INTO post_shares (post_id, shared_with_user_id) VALUES ($1, $2)` 95 | _, err = r.Repository.db.Exec(query, postId, userId) 96 | return err 97 | } 98 | 99 | // RemovePostShare removes a user from the post shares 100 | func (r *PostShareRepository) RemovePostShare(postId, userId int) error { 101 | query := `DELETE FROM post_shares WHERE post_id = $1 AND shared_with_user_id = $2` 102 | _, err := r.Repository.db.Exec(query, postId, userId) 103 | return err 104 | } 105 | 106 | // VerifyPostOwnership checks if the user owns the post 107 | func (r *PostShareRepository) VerifyPostOwnership(postId, userId int) (bool, error) { 108 | var ownerId int 109 | query := `SELECT user_id FROM posts WHERE id = $1` 110 | err := r.Repository.db.QueryRow(query, postId).Scan(&ownerId) 111 | 112 | if err != nil { 113 | return false, err 114 | } 115 | 116 | return ownerId == userId, nil 117 | } 118 | -------------------------------------------------------------------------------- /backend/pkg/controller/postShare.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "log" 5 | "social-network/pkg/model/request" 6 | "social-network/pkg/model/response" 7 | ) 8 | 9 | func (app *App) GetPostShares(payload *request.RequestT) any { 10 | data, ok := payload.Data.(*request.GetPostShares) 11 | if !ok { 12 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 13 | } 14 | 15 | userId := payload.Ctx.Value("user_id").(int) 16 | 17 | isOwner, err := app.repository.PostShare().VerifyPostOwnership(data.PostId, userId) 18 | if err != nil { 19 | log.Println("Error verifying post ownership:", err) 20 | return &response.Error{Code: 500, Cause: "Error verifying post ownership"} 21 | } 22 | if !isOwner { 23 | return &response.Error{Code: 403, Cause: "You don't have permission to manage this post"} 24 | } 25 | 26 | currentShares, err := app.repository.PostShare().GetPostShares(data.PostId) 27 | if err != nil { 28 | log.Println("Error getting post shares:", err) 29 | return &response.Error{Code: 500, Cause: "Error retrieving post shares"} 30 | } 31 | 32 | availableUsers, err := app.repository.PostShare().GetAvailableUsersToShare(data.PostId, userId) 33 | if err != nil { 34 | log.Println("Error getting available users:", err) 35 | return &response.Error{Code: 500, Cause: "Error retrieving available users"} 36 | } 37 | 38 | for _, user := range currentShares { 39 | user.IsAccepted = true 40 | } 41 | for _, user := range availableUsers { 42 | user.IsAccepted = false 43 | } 44 | 45 | allUsers := append(currentShares, availableUsers...) 46 | 47 | return &response.GetPostShares{ 48 | AllUsers: allUsers, 49 | Success: true, 50 | } 51 | } 52 | 53 | func (app *App) AddPostShare(payload *request.RequestT) any { 54 | data, ok := payload.Data.(*request.AddPostShare) 55 | if !ok { 56 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 57 | } 58 | 59 | userId := payload.Ctx.Value("user_id").(int) 60 | 61 | isOwner, err := app.repository.PostShare().VerifyPostOwnership(data.PostId, userId) 62 | if err != nil { 63 | log.Println("Error verifying post ownership:", err) 64 | return &response.Error{Code: 500, Cause: "Error verifying post ownership"} 65 | } 66 | if !isOwner { 67 | return &response.Error{Code: 403, Cause: "You don't have permission to manage this post"} 68 | } 69 | 70 | err = app.repository.PostShare().AddPostShare(data.PostId, data.UserId) 71 | if err != nil { 72 | log.Println("Error adding post share:", err) 73 | return &response.Error{Code: 500, Cause: "Error adding post share"} 74 | } 75 | 76 | return &response.AddPostShare{ 77 | Message: "User added successfully", 78 | Success: true, 79 | } 80 | } 81 | 82 | func (app *App) RemovePostShare(payload *request.RequestT) any { 83 | data, ok := payload.Data.(*request.RemovePostShare) 84 | if !ok { 85 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 86 | } 87 | 88 | userId := payload.Ctx.Value("user_id").(int) 89 | 90 | isOwner, err := app.repository.PostShare().VerifyPostOwnership(data.PostId, userId) 91 | if err != nil { 92 | log.Println("Error verifying post ownership:", err) 93 | return &response.Error{Code: 500, Cause: "Error verifying post ownership"} 94 | } 95 | if !isOwner { 96 | return &response.Error{Code: 403, Cause: "You don't have permission to manage this post"} 97 | } 98 | 99 | err = app.repository.PostShare().RemovePostShare(data.PostId, data.UserId) 100 | if err != nil { 101 | log.Println("Error removing post share:", err) 102 | return &response.Error{Code: 500, Cause: "Error removing post share"} 103 | } 104 | 105 | return &response.RemovePostShare{ 106 | Message: "User removed successfully", 107 | Success: true, 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /backend/pkg/model/request/register.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "regexp" 5 | "social-network/pkg/model/response" 6 | "strings" 7 | "time" 8 | 9 | "golang.org/x/crypto/bcrypt" 10 | ) 11 | 12 | type Register struct { 13 | Email string `json:"email"` 14 | Password string `json:"password"` 15 | Firstname string `json:"firstname"` 16 | Lastname string `json:"lastname"` 17 | Birth string `json:"birth"` 18 | Nickname string `json:"nickname"` 19 | About string `json:"about"` 20 | Avatar string `json:"avatar"` 21 | } 22 | 23 | var ( 24 | emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) 25 | nameRegex = regexp.MustCompile(`^[a-zA-Z]{1,50}$`) 26 | nicknameRegex = regexp.MustCompile(`^[a-zA-Z0-9.]{3,25}$`) 27 | ) 28 | 29 | func (r *Register) Validate() (err *response.RegisterError) { 30 | err = &response.RegisterError{} 31 | err.Code = 400 32 | r.Email = strings.ToLower(strings.TrimSpace(r.Email)) 33 | r.Firstname = strings.TrimSpace(r.Firstname) 34 | r.Lastname = strings.TrimSpace(r.Lastname) 35 | r.Nickname = strings.ToLower(strings.TrimSpace(r.Nickname)) 36 | r.About = strings.TrimSpace(r.About) 37 | 38 | if !emailRegex.MatchString(r.Email) { 39 | err.Field = "email" 40 | err.Cause = "invalid email format" 41 | return 42 | } 43 | 44 | if !nameRegex.MatchString(r.Firstname) { 45 | err.Field = "firstName" 46 | err.Cause = "only letters allowed, 1-50 chars" 47 | return 48 | } 49 | 50 | if !nameRegex.MatchString(r.Lastname) { 51 | err.Field = "lastName" 52 | err.Cause = "only letters allowed, 1-50 chars" 53 | return 54 | } 55 | 56 | if len(r.Nickname) > 0 && !nicknameRegex.MatchString(r.Nickname) { 57 | err.Field = "nickname" 58 | err.Cause = "3-25 chars, letters/numbers/dots only" 59 | return 60 | } 61 | 62 | if len(r.About) > 1000 { 63 | err.Field = "about" 64 | err.Cause = "about section too long (max 1000 characters)" 65 | return 66 | } 67 | 68 | if passErr := r.ValidatePassword(); passErr != "" { 69 | err.Field = "password" 70 | err.Cause = passErr 71 | return 72 | } 73 | 74 | if birthErr := r.ValidateBirth(); birthErr != "" { 75 | err.Field = "birth" 76 | err.Cause = birthErr 77 | return 78 | } 79 | if hashErr := r.HashPassword(); hashErr != "" { 80 | err.Code = 500 81 | err.Field = "password" 82 | err.Cause = hashErr 83 | } 84 | return nil 85 | } 86 | 87 | func (r *Register) ValidateBirth() string { 88 | now := time.Now() 89 | newTime, err := time.Parse(time.DateOnly, r.Birth) 90 | if err != nil { 91 | return "invalid birth date format, use mm-DD-YYYY" 92 | } 93 | if newTime.After(now) { 94 | return "birth date must be in the past" 95 | } 96 | if !(newTime.After(now.Add(-time.Hour*24*365*100)) && newTime.Before(now.Add(-time.Hour*24*365*18))) { 97 | return "birth date must be Between 18 and 100 years" 98 | } 99 | 100 | return "" 101 | } 102 | 103 | func (r *Register) HashPassword() string { 104 | passwd, err := bcrypt.GenerateFromPassword([]byte(r.Password), bcrypt.DefaultCost) 105 | if err != nil { 106 | //log in error file 107 | return "Can't hash password" 108 | } 109 | r.Password = string(passwd) 110 | return "" 111 | } 112 | 113 | func (r *Register) ValidatePassword() (err string) { 114 | if len(r.Password) < 6 || len(r.Password) > 100 { 115 | return "password must be 6-100 characters" 116 | } 117 | hasLetter := false 118 | hasDigit := false 119 | for _, c := range r.Password { 120 | switch { 121 | case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z': 122 | hasLetter = true 123 | case c >= '0' && c <= '9': 124 | hasDigit = true 125 | } 126 | } 127 | 128 | if !hasLetter || !hasDigit { 129 | return "must contain both letters and digits" 130 | } 131 | 132 | return "" 133 | } 134 | -------------------------------------------------------------------------------- /backend/pkg/model/request/types.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | type Login struct { 4 | Email string `json:"email"` 5 | Password string `json:"password"` 6 | } 7 | 8 | type GetPosts struct { 9 | StartId int `json:"startId"` 10 | } 11 | 12 | type GetPost struct { 13 | PostId int `json:"postId"` 14 | } 15 | 16 | type AddPost struct { 17 | Caption string `json:"caption"` 18 | Privacy string `json:"privacy"` 19 | Image string `json:"image"` 20 | } 21 | 22 | type ReactToPost struct { 23 | PostId int `json:"postId"` 24 | Reaction *bool `json:"reaction"` 25 | } 26 | 27 | type GetPostShares struct { 28 | PostId int `json:"postId"` 29 | } 30 | 31 | type AddPostShare struct { 32 | PostId int `json:"postId"` 33 | UserId int `json:"userId"` 34 | } 35 | 36 | type RemovePostShare struct { 37 | PostId int `json:"postId"` 38 | UserId int `json:"userId"` 39 | } 40 | 41 | type AddComment struct { 42 | PostId int `json:"postId"` 43 | Text string `json:"text"` 44 | Image string `json:"image"` 45 | } 46 | 47 | type GetComments struct { 48 | PostId int `json:"postId"` 49 | } 50 | 51 | type GetProfile struct { 52 | ProfileId int `json:"profileId"` 53 | } 54 | 55 | type SetProfilePrivacy struct { 56 | State bool `json:"state"` 57 | } 58 | 59 | type CreateGroup struct { 60 | Title string `json:"title"` 61 | Description string `json:"description"` 62 | Image string `json:"image"` 63 | } 64 | 65 | type GetGroupData struct { 66 | GroupId int `json:"groupId"` 67 | } 68 | 69 | type AddGroupPost struct { 70 | GroupId int `json:"groupId"` 71 | Caption string `json:"caption"` 72 | Image string `json:"image"` 73 | } 74 | 75 | type AddGroupEvent struct { 76 | GroupId int `json:"groupId"` 77 | Title string `json:"title"` 78 | Description string `json:"description"` 79 | Option1 string `json:"option1"` 80 | Option2 string `json:"option2"` 81 | Date string `json:"date"` 82 | Place string `json:"place"` 83 | } 84 | 85 | type GetGroupInviteUsers struct { 86 | GroupId int `json:"groupId"` 87 | } 88 | 89 | type InviteUserToGroup struct { 90 | GroupId int `json:"groupId"` 91 | UserId int `json:"userId"` 92 | } 93 | 94 | type RespondToGroupInvitation struct { 95 | GroupId int `json:"groupId"` 96 | Accept bool `json:"accept"` 97 | } 98 | 99 | type AddEventOption struct { 100 | GroupId int `json:"groupId"` 101 | EventId int `json:"eventId"` 102 | Option bool `json:"option"` 103 | } 104 | 105 | type RequestJoinGroup struct { 106 | GroupId int `json:"groupId"` 107 | } 108 | 109 | type RespondToJoinRequest struct { 110 | GroupId int `json:"groupId"` 111 | UserId int `json:"userId"` 112 | IsAccepted bool `json:"isAccepted"` 113 | } 114 | 115 | type AddGroupComment struct { 116 | PostId int `json:"postId"` 117 | Text string `json:"text"` 118 | Image string `json:"image"` 119 | GroupId int `json:"groupId"` 120 | } 121 | 122 | type GetGroupComments struct { 123 | PostId int `json:"postId"` 124 | GroupId int `json:"groupId"` 125 | } 126 | 127 | type ReactToGroupPost struct { 128 | PostId int `json:"postId"` 129 | GroupId int `json:"groupId"` 130 | Reaction *bool `json:"reaction"` 131 | } 132 | 133 | type GetGroupPost struct { 134 | PostId int `json:"postId"` 135 | } 136 | 137 | type RequestFollow struct { 138 | ProfileId int `json:"profileId"` 139 | } 140 | 141 | type AcceptFollow struct { 142 | ProfileId int `json:"profileId"` 143 | } 144 | 145 | type DeleteFollow struct { 146 | ProfileId int `json:"profileId"` 147 | IsFollower bool `json:"isFollower"` 148 | } 149 | 150 | type GetMessages struct { 151 | Id int `json:"id"` 152 | IsGroup bool `json:"isGroup"` 153 | } 154 | 155 | type AddMessage struct { 156 | Id int `json:"id"` 157 | IsGroup bool `json:"isGroup"` 158 | Message string `json:"message"` 159 | Session string `json:"session"` 160 | } 161 | 162 | type DeleteFollowNotification struct { 163 | ProfileId int `json:"profileId"` 164 | } 165 | 166 | type DeleteNewEventNotification struct { 167 | GroupId int `json:"groupId"` 168 | } 169 | 170 | type UpdateSeenMessageWS struct { 171 | Id int `json:"id"` 172 | IsGroup bool `json:"isGroup"` 173 | Session string `json:"session"` 174 | } 175 | -------------------------------------------------------------------------------- /backend/pkg/controller/notification.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "social-network/pkg/model/request" 7 | "social-network/pkg/model/response" 8 | "time" 9 | ) 10 | 11 | func (app *App) GetAllNotifications(payload *request.RequestT) any { 12 | userId := payload.Ctx.Value("user_id").(int) 13 | 14 | pmCount, err := app.repository.Message().CountUnreadPM(userId) 15 | if err != nil { 16 | log.Println("Error getting unread PM count:", err) 17 | pmCount = 0 18 | } 19 | 20 | groupMessageCount, err := app.repository.Message().CountUnreadGroup(userId) 21 | if err != nil { 22 | log.Println("Error getting unread group messages:", err) 23 | groupMessageCount = 0 24 | } 25 | 26 | messageUnread := pmCount + groupMessageCount 27 | 28 | groupRequests, err := app.repository.Group().CountPendingJoinRequests(userId) 29 | if err != nil { 30 | log.Println("Error getting group join requests:", err) 31 | groupRequests = 0 32 | } 33 | fmt.Println("Group Requests Count:", groupRequests) 34 | 35 | followRequests, err := app.repository.Follow().GetFollowRequestCount(userId) 36 | if err != nil { 37 | log.Println("Error getting follow requests:", err) 38 | followRequests = 0 39 | } 40 | publicFollowRequests, err := app.repository.Follow().CountPublicFollowRequests(userId) 41 | if err != nil { 42 | log.Println("Error getting public follow requests:", err) 43 | publicFollowRequests = 0 44 | } 45 | followRequests += publicFollowRequests 46 | 47 | eventCreatedCount, err := app.repository.Group().CountNewEvents(userId) 48 | if err != nil { 49 | log.Println("Error getting event created count:", err) 50 | eventCreatedCount = 0 51 | } 52 | groupRequests += eventCreatedCount 53 | 54 | invitations, err := app.repository.Group().GetGroupJoinInvitations(userId) 55 | if err != nil { 56 | log.Println("Error getting event created count:", err) 57 | invitations = 0 58 | } 59 | fmt.Println("Invitations Count:", invitations) 60 | groupRequests += invitations 61 | 62 | fmt.Println(userId, groupRequests, invitations) 63 | notifications := map[string]int{ 64 | "messageUnread": messageUnread, 65 | "groupRequests": groupRequests, 66 | "followRequests": followRequests, 67 | } 68 | totalCount := messageUnread + groupRequests + followRequests 69 | 70 | return &response.GetAllNotifications{ 71 | Notifications: notifications, 72 | TotalCount: totalCount, 73 | } 74 | } 75 | 76 | func (app *App) CheckNewFollowNotification(payload *request.RequestT) any { 77 | userId := payload.Ctx.Value("user_id").(int) 78 | 79 | users, err := app.repository.Follow().GetNewFollowers(userId) 80 | if err != nil { 81 | return &response.Error{Code: 500, Cause: "Database error"} 82 | } 83 | 84 | return &response.CheckNewFollowNotification{ 85 | HasNewFollow: len(users) > 0, 86 | NewFollowers: users, 87 | } 88 | } 89 | 90 | func (app *App) DeleteFollowNotification(payload *request.RequestT) any { 91 | data, ok := payload.Data.(*request.DeleteFollowNotification) 92 | if !ok { 93 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 94 | } 95 | userId := payload.Ctx.Value("user_id").(int) 96 | 97 | err := app.repository.Follow().DeleteNotif(data.ProfileId, userId) 98 | if err != nil { 99 | log.Println("Error deleting follow notification:", err) 100 | return &response.Error{Code: 500, Cause: "Error deleting follow notification"} 101 | } 102 | notification := map[string]any{ 103 | "type": "notifications", 104 | "followerId": data.ProfileId, 105 | "message": "unfollow ", 106 | "timestamp": time.Now().Unix(), 107 | } 108 | app.sendNotificationToUser(userId, notification) 109 | return &response.DeleteFollowNotification{ 110 | Message: "Notification deleted", 111 | } 112 | } 113 | 114 | func (app *App) DeleteNewEventNotification(payload *request.RequestT) any { 115 | data, ok := payload.Data.(*request.DeleteNewEventNotification) 116 | if !ok { 117 | return &response.Error{Code: 400, Cause: "Invalid payload type"} 118 | } 119 | userId := payload.Ctx.Value("user_id").(int) 120 | 121 | err := app.repository.Follow().DeleteEventNotif(data.GroupId, userId) 122 | if err != nil { 123 | log.Println("Error deleting follow notification:", err) 124 | return &response.Error{Code: 500, Cause: "Error deleting follow notification"} 125 | } 126 | notification := map[string]any{ 127 | "type": "notifications", 128 | } 129 | app.sendNotificationToUser(userId, notification) 130 | return &response.DeleteNewEventNotification{ 131 | Message: "Notification deleted", 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /backend/pkg/repository/groupReactionRepo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "social-network/pkg/model" 7 | ) 8 | 9 | func (r *GroupRepository) UpsertGroupReaction(userId, postId int, isLike *bool) error { 10 | var exists bool 11 | err := r.Repository.db.QueryRow("SELECT EXISTS(SELECT 1 FROM group_posts WHERE id = $1)", postId).Scan(&exists) 12 | if err != nil { 13 | return err 14 | } 15 | if !exists { 16 | return errors.New("group post does not exist") 17 | } 18 | 19 | if isLike == nil { 20 | _, err := r.Repository.db.Exec("DELETE FROM group_post_reactions WHERE user_id = $1 AND post_id = $2", userId, postId) 21 | return err 22 | } 23 | 24 | _, err = r.Repository.db.Exec(` 25 | INSERT INTO group_post_reactions (user_id, post_id, is_like) 26 | VALUES ($1, $2, $3) 27 | ON CONFLICT(user_id, post_id) 28 | DO UPDATE SET is_like = $3`, 29 | userId, postId, isLike) 30 | 31 | return err 32 | } 33 | 34 | func (r *GroupRepository) GetGroupUserReaction(userId, postId int) (*bool, error) { 35 | var isLike sql.NullBool 36 | err := r.Repository.db.QueryRow( 37 | "SELECT is_like FROM group_post_reactions WHERE user_id = $1 AND post_id = $2", 38 | userId, postId, 39 | ).Scan(&isLike) 40 | 41 | if err == sql.ErrNoRows { 42 | return nil, nil // No reaction 43 | } 44 | 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | if !isLike.Valid { 50 | return nil, nil 51 | } 52 | 53 | value := isLike.Bool 54 | return &value, nil 55 | } 56 | 57 | func (r *GroupRepository) GetGroupReactionCounts(postId int) (model.ReactionCounts, error) { 58 | counts := model.ReactionCounts{} 59 | 60 | err := r.Repository.db.QueryRow( 61 | "SELECT COUNT(*) FROM group_post_reactions WHERE post_id = $1 AND is_like = TRUE", 62 | postId, 63 | ).Scan(&counts.Likes) 64 | 65 | if err != nil { 66 | return counts, err 67 | } 68 | 69 | err = r.Repository.db.QueryRow( 70 | "SELECT COUNT(*) FROM group_post_reactions WHERE post_id = $1 AND is_like = FALSE", 71 | postId, 72 | ).Scan(&counts.Dislikes) 73 | 74 | return counts, err 75 | } 76 | 77 | func (r *GroupRepository) GetGroupReactionsForPosts(userId int, postIds []int) (map[int]model.ReactionCounts, error) { 78 | if len(postIds) == 0 { 79 | return make(map[int]model.ReactionCounts), nil 80 | } 81 | 82 | // Initialize result map 83 | result := make(map[int]model.ReactionCounts) 84 | 85 | // Prepare placeholders for query 86 | placeholders := "" 87 | args := make([]interface{}, len(postIds)) 88 | 89 | for i, id := range postIds { 90 | if i > 0 { 91 | placeholders += ", " 92 | } 93 | placeholders += "$" + string(rune('2'+i)) 94 | args[i] = id 95 | } 96 | 97 | // Get all likes and dislikes counts 98 | query := ` 99 | SELECT post_id, 100 | SUM(CASE WHEN is_like = TRUE THEN 1 ELSE 0 END) as likes, 101 | SUM(CASE WHEN is_like = FALSE THEN 1 ELSE 0 END) as dislikes 102 | FROM group_post_reactions 103 | WHERE post_id IN (` + placeholders + `) 104 | GROUP BY post_id 105 | ` 106 | 107 | rows, err := r.Repository.db.Query(query, args...) 108 | if err != nil { 109 | return nil, err 110 | } 111 | defer rows.Close() 112 | 113 | for rows.Next() { 114 | var postId, likes, dislikes int 115 | if err := rows.Scan(&postId, &likes, &dislikes); err != nil { 116 | return nil, err 117 | } 118 | 119 | result[postId] = model.ReactionCounts{ 120 | Likes: likes, 121 | Dislikes: dislikes, 122 | } 123 | } 124 | 125 | // Get user's own reactions 126 | if userId > 0 { 127 | query = ` 128 | SELECT post_id, is_like 129 | FROM group_post_reactions 130 | WHERE user_id = $1 AND post_id IN (` + placeholders + `) 131 | ` 132 | 133 | args = append([]interface{}{userId}, args...) 134 | 135 | rows, err := r.Repository.db.Query(query, args...) 136 | if err != nil { 137 | return nil, err 138 | } 139 | defer rows.Close() 140 | 141 | for rows.Next() { 142 | var postId int 143 | var isLike sql.NullBool 144 | 145 | if err := rows.Scan(&postId, &isLike); err != nil { 146 | return nil, err 147 | } 148 | 149 | counts, exists := result[postId] 150 | if !exists { 151 | counts = model.ReactionCounts{} 152 | } 153 | 154 | if isLike.Valid { 155 | value := isLike.Bool 156 | counts.UserReaction = &value 157 | } 158 | 159 | result[postId] = counts 160 | } 161 | } 162 | 163 | // Initialize counts for posts without reactions 164 | for _, postId := range postIds { 165 | if _, exists := result[postId]; !exists { 166 | result[postId] = model.ReactionCounts{ 167 | Likes: 0, 168 | Dislikes: 0, 169 | } 170 | } 171 | } 172 | 173 | return result, nil 174 | } 175 | -------------------------------------------------------------------------------- /frontend/app/home/(posts)/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useEffect, useState } from "react"; 4 | import CreatePostModal from "@/components/create-post-modal"; 5 | import Post from "@/components/post"; 6 | import { Post as PostType, Reaction } from "@/types/post"; 7 | import { useGlobalAPIHelper } from "@/helpers/GlobalAPIHelper"; 8 | 9 | export default function PostsPage() { 10 | const { apiCall } = useGlobalAPIHelper(); 11 | const [posts, setPosts] = useState([]); 12 | const [currentUserId, setCurrentUserId] = useState(null); 13 | const [isModalOpen, setIsModalOpen] = useState(false); 14 | const [isLoading, setIsLoading] = useState(true); 15 | const [hasMore, setHasMore] = useState(true); 16 | const fetchPosts = useCallback(async () => { 17 | setIsLoading(true); 18 | const data = await apiCall( 19 | { 20 | type: "get-posts", 21 | data: { startId: 0 }, 22 | }, 23 | "POST", 24 | "getPosts" 25 | ); 26 | 27 | if (data?.posts) { 28 | const { posts, userid } = data; 29 | setPosts(posts); 30 | setCurrentUserId(userid); 31 | setHasMore(posts.length === 10); 32 | } 33 | 34 | setIsLoading(false); 35 | }, [apiCall]); 36 | 37 | const fetchMorePosts = async () => { 38 | if (posts.length === 0) return; 39 | 40 | const lastId = posts[posts.length - 1].id; 41 | setIsLoading(true); 42 | 43 | const data = await apiCall( 44 | { 45 | type: "get-posts", 46 | data: { startId: lastId }, 47 | }, 48 | "POST", 49 | "getPosts" 50 | ); 51 | 52 | if (data?.posts?.length) { 53 | setPosts((prev) => [...prev, ...data.posts]); 54 | setHasMore(data.posts.length === 10); 55 | } 56 | 57 | setIsLoading(false); 58 | }; 59 | 60 | useEffect(() => { 61 | fetchPosts(); 62 | }, [fetchPosts]); 63 | 64 | const handleCreatePost = async (newPost: { 65 | image: string; 66 | caption: string; 67 | privacy?: string; 68 | groupId?: number; 69 | }) => { 70 | try { 71 | const data = await apiCall( 72 | { 73 | type: "add-post", 74 | data: newPost, 75 | }, 76 | "POST", 77 | "addPost" 78 | ); 79 | 80 | if (data?.post) { 81 | setPosts([data.post, ...posts]); 82 | } else { 83 | return; 84 | } 85 | } catch (err) { 86 | console.log(err); 87 | } 88 | 89 | setIsModalOpen(false); 90 | }; 91 | 92 | // Function to update post reaction in the posts list 93 | const handleReactionUpdate = (postId: number, reactionData: Reaction) => { 94 | setPosts((currentPosts) => 95 | currentPosts.map((post) => 96 | post.id === postId 97 | ? { 98 | ...post, 99 | reactions: { 100 | likes: reactionData.likes, 101 | dislikes: reactionData.dislikes, 102 | userReaction: reactionData.userReaction, 103 | }, 104 | } 105 | : post 106 | ) 107 | ); 108 | }; 109 | 110 | return ( 111 |
112 |
113 |

Posts

114 | 120 |
121 |
122 | {isLoading ? ( 123 |
Loading posts...
124 | ) : posts.length === 0 ? ( 125 |
No posts yet. Create your first post!
126 | ) : ( 127 | posts.map((post) => { 128 | console.log( 129 | `Post ${post.id} - Owner: ${post.user_id}, Current User: ${currentUserId}, Privacy: ${post.privacy}` 130 | ); 131 | return ( 132 | 138 | ); 139 | }) 140 | )} 141 | {!isLoading && posts.length > 0 && hasMore && ( 142 | 145 | )} 146 |
147 | 148 | {isModalOpen && ( 149 | setIsModalOpen(false)} 151 | onSubmit={handleCreatePost} 152 | /> 153 | )} 154 |
155 | ); 156 | } 157 | -------------------------------------------------------------------------------- /backend/pkg/repository/reactionRepo.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "social-network/pkg/model" 7 | "strings" 8 | ) 9 | 10 | type ReactionRepository struct { 11 | Repository *Repository 12 | } 13 | 14 | func (r *ReactionRepository) UpsertReaction(userId, postId int, isLike *bool) error { 15 | var exists bool 16 | err := r.Repository.db.QueryRow("SELECT EXISTS(SELECT 1 FROM posts WHERE id = $1)", postId).Scan(&exists) 17 | if err != nil { 18 | return err 19 | } 20 | if !exists { 21 | return errors.New("post does not exist") 22 | } 23 | 24 | if isLike == nil { 25 | _, err := r.Repository.db.Exec("DELETE FROM post_reactions WHERE user_id = $1 AND post_id = $2", userId, postId) 26 | return err 27 | } 28 | 29 | _, err = r.Repository.db.Exec(` 30 | INSERT INTO post_reactions (user_id, post_id, is_like) 31 | VALUES ($1, $2, $3) 32 | ON CONFLICT(user_id, post_id) 33 | DO UPDATE SET is_like = $3`, 34 | userId, postId, isLike) 35 | 36 | return err 37 | } 38 | 39 | func (r *ReactionRepository) GetUserReaction(userId, postId int) (*bool, error) { 40 | var isLike sql.NullBool 41 | err := r.Repository.db.QueryRow( 42 | "SELECT is_like FROM post_reactions WHERE user_id = $1 AND post_id = $2", 43 | userId, postId, 44 | ).Scan(&isLike) 45 | 46 | if err == sql.ErrNoRows { 47 | return nil, nil // No reaction 48 | } 49 | 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | if !isLike.Valid { 55 | return nil, nil 56 | } 57 | 58 | value := isLike.Bool 59 | return &value, nil 60 | } 61 | 62 | func (r *ReactionRepository) GetReactionCounts(postId int) (model.ReactionCounts, error) { 63 | counts := model.ReactionCounts{} 64 | 65 | err := r.Repository.db.QueryRow( 66 | "SELECT COUNT(*) FROM post_reactions WHERE post_id = $1 AND is_like = TRUE", 67 | postId, 68 | ).Scan(&counts.Likes) 69 | 70 | if err != nil { 71 | return counts, err 72 | } 73 | 74 | err = r.Repository.db.QueryRow( 75 | "SELECT COUNT(*) FROM post_reactions WHERE post_id = $1 AND is_like = FALSE", 76 | postId, 77 | ).Scan(&counts.Dislikes) 78 | 79 | return counts, err 80 | } 81 | 82 | func (r *ReactionRepository) GetReactionsForPosts(userId int, postIds []int) (map[int]model.ReactionCounts, error) { 83 | if len(postIds) == 0 { 84 | return make(map[int]model.ReactionCounts), nil 85 | } 86 | 87 | // Initialize result map 88 | result := make(map[int]model.ReactionCounts) 89 | 90 | // Generate ? placeholders 91 | placeholders := make([]string, len(postIds)) 92 | args := make([]interface{}, len(postIds)) 93 | for i, id := range postIds { 94 | args[i] = id 95 | placeholders[i] = "?" 96 | } 97 | inClause := strings.Join(placeholders, ",") 98 | 99 | // Get all likes and dislikes counts 100 | query := ` 101 | SELECT post_id, 102 | SUM(CASE WHEN is_like = TRUE THEN 1 ELSE 0 END) as likes, 103 | SUM(CASE WHEN is_like = FALSE THEN 1 ELSE 0 END) as dislikes 104 | FROM post_reactions 105 | WHERE post_id IN (` + inClause + `) 106 | GROUP BY post_id 107 | ` 108 | rows, err := r.Repository.db.Query(query, args...) 109 | if err != nil { 110 | return nil, err 111 | } 112 | defer rows.Close() 113 | 114 | for rows.Next() { 115 | var postId, likes, dislikes int 116 | if err := rows.Scan(&postId, &likes, &dislikes); err != nil { 117 | return nil, err 118 | } 119 | 120 | result[postId] = model.ReactionCounts{ 121 | Likes: likes, 122 | Dislikes: dislikes, 123 | } 124 | } 125 | 126 | // Get user's own reactions 127 | if userId > 0 { 128 | query = ` 129 | SELECT post_id, is_like 130 | FROM post_reactions 131 | WHERE user_id = $1 AND post_id IN (` + inClause + `) 132 | ` 133 | 134 | args = append([]interface{}{userId}, args...) 135 | 136 | rows, err := r.Repository.db.Query(query, args...) 137 | if err != nil { 138 | return nil, err 139 | } 140 | defer rows.Close() 141 | 142 | for rows.Next() { 143 | var postId int 144 | var isLike sql.NullBool 145 | 146 | if err := rows.Scan(&postId, &isLike); err != nil { 147 | return nil, err 148 | } 149 | 150 | counts, exists := result[postId] 151 | if !exists { 152 | counts = model.ReactionCounts{} 153 | } 154 | 155 | if isLike.Valid { 156 | value := isLike.Bool 157 | counts.UserReaction = &value 158 | } 159 | 160 | result[postId] = counts 161 | } 162 | } 163 | 164 | // Initialize counts for posts without reactions 165 | for _, postId := range postIds { 166 | if _, exists := result[postId]; !exists { 167 | result[postId] = model.ReactionCounts{ 168 | Likes: 0, 169 | Dislikes: 0, 170 | } 171 | } 172 | } 173 | 174 | return result, nil 175 | } 176 | -------------------------------------------------------------------------------- /backend/pkg/controller/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "social-network/pkg/model/request" 9 | "social-network/pkg/model/response" 10 | "social-network/pkg/repository" 11 | "sync" 12 | "time" 13 | 14 | "slices" 15 | 16 | "github.com/gorilla/websocket" 17 | ) 18 | 19 | type App struct { 20 | repository repository.Repository 21 | clients map[int][]*Client 22 | mu sync.Mutex 23 | upgrader *websocket.Upgrader 24 | } 25 | 26 | type Client struct { 27 | Session string 28 | ActiveTime time.Time 29 | UserId int 30 | Connection *websocket.Conn 31 | } 32 | 33 | func NewApp(repository *repository.Repository) *App { 34 | upgrader := &websocket.Upgrader{ 35 | CheckOrigin: func(r *http.Request) bool { 36 | return true 37 | }, 38 | ReadBufferSize: 1024, 39 | WriteBufferSize: 1024, 40 | } 41 | 42 | return &App{ 43 | repository: *repository, 44 | upgrader: upgrader, 45 | clients: make(map[int][]*Client), 46 | } 47 | } 48 | 49 | func (app *App) ServeError(w http.ResponseWriter, err *response.Error) { 50 | fmt.Println("Error:", err.Cause) 51 | status, body := response.Marshal(err) 52 | w.Header().Set("Content-Type", "application/json") 53 | w.WriteHeader(status) 54 | w.Write(body) 55 | } 56 | 57 | func (app *App) addClient(userid int, session string, client *websocket.Conn) *Client { 58 | app.mu.Lock() 59 | defer app.mu.Unlock() 60 | c := &Client{UserId: userid, Session: session, ActiveTime: time.Now(), Connection: client} 61 | app.clients[userid] = append(app.clients[userid], c) 62 | return c 63 | } 64 | 65 | func (app *App) removeClient(client *Client) { 66 | app.mu.Lock() 67 | defer app.mu.Unlock() 68 | 69 | for i, c := range app.clients[client.UserId] { 70 | if c == client { 71 | app.clients[client.UserId] = slices.Delete(app.clients[client.UserId], i, i+1) 72 | break 73 | } 74 | } 75 | 76 | if len(app.clients[client.UserId]) == 0 { 77 | delete(app.clients, client.UserId) 78 | } 79 | } 80 | 81 | func (app *App) readMessage(conn *websocket.Conn, client *Client) { 82 | for { 83 | 84 | _, payload, err := conn.ReadMessage() 85 | if err != nil { 86 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 87 | log.Printf("Error reading message: %v", err) 88 | } 89 | app.removeClient(client) 90 | break 91 | } 92 | 93 | reqType, reqBody, err := request.Unmarshal(payload) 94 | 95 | if err != nil { 96 | log.Println("Unmarshal payload err", err) 97 | return 98 | } 99 | 100 | session := app.GetReflectValue(reqBody, "Session", "string") 101 | 102 | fmt.Println("session", session) 103 | if session == nil || session.(string) != client.Session { 104 | app.ShowMessage(client, &response.Error{ 105 | Code: 401, 106 | Cause: "unauthorized: invalid session111", 107 | }) 108 | app.removeClient(client) 109 | break 110 | } 111 | 112 | uid, err := app.repository.Session().FindUserIDBySession(session.(string)) 113 | if err != nil { 114 | app.ShowMessage(client, &response.Error{ 115 | Code: 401, 116 | Cause: "unauthorized: invalid session", 117 | }) 118 | app.removeClient(client) 119 | break 120 | } 121 | reqBody.Ctx = context.WithValue(context.Background(), "user_id", uid) 122 | switch reqType { 123 | case "update-seen-message": 124 | app.UpdateSeenMessageWS(reqBody) 125 | case "add-message": 126 | response, err := app.AddMessage(reqBody) 127 | if err != nil { 128 | app.ShowMessage(client, err) 129 | continue 130 | } 131 | app.SentToActiveRecipient(response) 132 | } 133 | } 134 | } 135 | 136 | func (app *App) SentToActiveRecipient(response *response.AddMessage) { 137 | 138 | senderId := response.Message.SenderId 139 | RecipientId := response.Message.RecipientId 140 | groupId := response.Message.GroupId 141 | 142 | for _, c := range app.clients[senderId] { 143 | response.Message.IsOwned = true 144 | app.ShowMessage(c, response) 145 | } 146 | 147 | if RecipientId != 0 { 148 | // Brodcast to RecipientId 149 | for _, c := range app.clients[RecipientId] { 150 | response.Message.IsOwned = false 151 | app.ShowMessage(c, response) 152 | } 153 | } else { 154 | // Brodcast to group members 155 | users, err := app.repository.Group().GetGroupMembers(groupId) 156 | response.Message.IsOwned = false 157 | 158 | if err != nil { 159 | fmt.Println("Error broadcasting to group members:", err) 160 | return 161 | } 162 | 163 | for _, u := range users { 164 | if u.ID != senderId { 165 | for _, c := range app.clients[u.ID] { 166 | response.Message.IsOwned = false 167 | app.ShowMessage(c, response) 168 | } 169 | } 170 | } 171 | } 172 | } 173 | 174 | func (app *App) ShowMessage(client *Client, res any) { 175 | if client == nil { 176 | return 177 | } 178 | 179 | _, data := response.Marshal(res) 180 | 181 | client.Connection.WriteMessage(websocket.TextMessage, data) 182 | } 183 | -------------------------------------------------------------------------------- /backend/pkg/model/response/types.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import "social-network/pkg/model" 4 | 5 | type Login struct { 6 | Session string `json:"session"` 7 | UserId int `json:"user_id"` 8 | Username string `json:"username"` 9 | } 10 | 11 | type RegisterError struct { 12 | Error 13 | Field string `json:"field"` 14 | } 15 | 16 | type GetPosts struct { 17 | Posts []*model.Post `json:"posts"` 18 | Userid int `json:"userid"` 19 | } 20 | 21 | type GetPost struct { 22 | Userid int `json:"userid,omitempty"` 23 | Post *model.Post `json:"post,omitempty"` 24 | } 25 | 26 | type AddPost struct { 27 | Post *model.Post `json:"post,omitempty"` 28 | } 29 | 30 | type ReactToPost struct { 31 | Userid int `json:"userid"` 32 | Post *model.Post `json:"post,omitempty"` 33 | Success bool `json:"success"` 34 | } 35 | 36 | type GetPostShares struct { 37 | AllUsers []*model.User `json:"all_users"` 38 | Success bool `json:"success"` 39 | } 40 | 41 | type AddPostShare struct { 42 | Message string `json:"message"` 43 | Success bool `json:"success"` 44 | } 45 | 46 | type RemovePostShare struct { 47 | Message string `json:"message"` 48 | Success bool `json:"success"` 49 | } 50 | 51 | type GetProfile struct { 52 | User *model.User `json:"user"` 53 | CurrentUser *model.User `json:"currentUser,omitempty"` 54 | } 55 | 56 | type GetProfiles struct { 57 | FollowRequests []*model.User `json:"followRequests"` 58 | AllUsers []*model.User `json:"allUsers"` 59 | CurrentUser *model.User `json:"currentUser"` 60 | } 61 | 62 | type SetProfilePrivacy struct { 63 | Success bool `json:"success"` 64 | } 65 | 66 | type AddGroupComment struct { 67 | Post *model.Post `json:"post"` 68 | } 69 | 70 | type GetGroupComments struct { 71 | Post *model.Post `json:"post"` 72 | } 73 | 74 | type ReactToGroupPost struct { 75 | Post *model.Post `json:"post"` 76 | } 77 | 78 | type GetGroupPost struct { 79 | Post *model.Post `json:"post"` 80 | } 81 | 82 | type GetAllNotifications struct { 83 | Notifications map[string]int `json:"notifications"` 84 | TotalCount int `json:"totalCount"` 85 | } 86 | 87 | type CheckNewFollowNotification struct { 88 | HasNewFollow bool `json:"hasNewFollow"` 89 | NewFollowers []*model.User `json:"newFollowers"` 90 | } 91 | 92 | type DeleteFollowNotification struct { 93 | Message string `json:"message"` 94 | } 95 | 96 | type DeleteNewEventNotification struct { 97 | Message string `json:"message"` 98 | } 99 | 100 | type CreateGroup struct { 101 | Success bool `json:"success"` 102 | Group *model.Group `json:"group,omitempty"` 103 | } 104 | 105 | type GetGroups struct { 106 | GroupInvites []*model.Group `json:"groupInvites"` 107 | JoinRequests []*model.Group `json:"joinRequests"` 108 | All []*model.Group `json:"all"` 109 | } 110 | 111 | type GetGroupData struct { 112 | Group *model.Group `json:"group"` 113 | } 114 | 115 | type AddGroupPost struct { 116 | Post *model.Post `json:"post"` 117 | } 118 | 119 | type AddGroupEvent struct { 120 | Success bool `json:"success"` 121 | Event *model.GroupEvent `json:"event,omitempty"` 122 | } 123 | 124 | type GetGroupInviteUsers struct { 125 | Users []*model.User `json:"users"` 126 | } 127 | 128 | type InviteUserToGroup struct { 129 | Success bool `json:"success"` 130 | Message string `json:"message,omitempty"` 131 | } 132 | 133 | type RespondToGroupInvitation struct { 134 | Success bool `json:"success"` 135 | Message string `json:"message,omitempty"` 136 | } 137 | 138 | type AddEventOption struct { 139 | Option *model.EventOption `json:"option"` 140 | } 141 | 142 | type RequestJoinGroup struct { 143 | Success bool `json:"success"` 144 | Message string `json:"message,omitempty"` 145 | } 146 | 147 | type GetJoinRequestCount struct { 148 | Count int `json:"count"` 149 | } 150 | 151 | type RespondToJoinRequest struct { 152 | Success bool `json:"success"` 153 | Message string `json:"message,omitempty"` 154 | } 155 | 156 | type GetUnreadMessagesCount struct { 157 | Count int `json:"count"` 158 | } 159 | 160 | type RequestFollow struct { 161 | Success bool `json:"success"` 162 | Message string `json:"message,omitempty"` 163 | } 164 | 165 | type AcceptFollow struct { 166 | Success bool `json:"success"` 167 | Message string `json:"message,omitempty"` 168 | } 169 | 170 | type DeleteFollow struct { 171 | Success bool `json:"success"` 172 | Message string `json:"message,omitempty"` 173 | } 174 | 175 | type GetChat struct { 176 | PrivateConvs []*model.Conv `json:"privateConvs"` 177 | GroupConvs []*model.Conv `json:"groupConvs"` 178 | NewConvs []*model.Conv `json:"newConvs"` 179 | } 180 | 181 | type GetMessages struct { 182 | Messages []*model.Message `json:"messages"` 183 | } 184 | 185 | type AddMessage struct { 186 | Type string `json:"type"` 187 | Message *model.Message `json:"message"` 188 | IsGroup bool `json:"isGroup"` 189 | } 190 | 191 | type Logout struct { 192 | Message string `json:"message"` 193 | } 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Social Network Project 2 | 3 | ## Description 4 | This project involves building a Facebook-like social network that provides the following features: 5 | 6 | - User authentication with sessions and cookies 7 | - Follower system with public and private profiles 8 | - Posts with privacy settings and multimedia support 9 | - Group creation, membership management, and events 10 | - Real-time chat using WebSockets 11 | - Notifications for various actions and events 12 | 13 | Both the backend and frontend are containerized using Docker to ensure easy deployment and maintainability. 14 | 15 | --- 16 | 17 | ## Features 18 | 19 | ### Authentication 20 | - Registration with the following fields: 21 | - **Mandatory:** Email, Password, First Name, Last Name, Date of Birth 22 | - **Optional:** Avatar/Image, Nickname, About Me 23 | - Login with session persistence using cookies 24 | - Logout functionality 25 | 26 | ### Followers 27 | - Follow/unfollow users 28 | - Handle follow requests for private profiles 29 | - Automatic follow for public profiles 30 | 31 | ### Profiles 32 | - Display user information, activity, and posts 33 | - Show followers and following users 34 | - Toggle between public and private profiles 35 | 36 | ### Posts 37 | - Create posts with images or GIFs 38 | - Comment on posts 39 | - Privacy settings: 40 | - Public 41 | - Almost private (followers only) 42 | - Private (specific followers only) 43 | 44 | ### Groups 45 | - Create and manage groups with title and description 46 | - Invite members and accept/decline requests 47 | - Browse and request to join groups 48 | - Group-specific posts and comments 49 | - Event creation with RSVP options (e.g., "Going" or "Not Going") 50 | 51 | ### Chat 52 | - Real-time private messaging between users (via WebSockets) 53 | - Group chat rooms 54 | - Emoji support 55 | 56 | ### Notifications 57 | - Notifications for: 58 | - Follow requests 59 | - Group invitations 60 | - Requests to join a group 61 | - New group events 62 | - Separate UI for private messages and notifications 63 | 64 | --- 65 | 66 | ## Technologies Used 67 | 68 | ### Frontend 69 | - **Languages:** HTML, CSS, JavaScript 70 | - **Frameworks:** (Choose one: Next.js, Vue.js, Svelte, Mithril, etc.) 71 | 72 | ### Backend 73 | - **Language:** Go 74 | - **Database:** SQLite 75 | - **Packages:** 76 | - `golang-migrate` for migrations 77 | - `bcrypt` for password hashing 78 | - `gorilla/websocket` for real-time communication 79 | - `uuid` for generating unique IDs 80 | - **Web Server:** Caddy (or custom-built server) 81 | 82 | ### Containerization 83 | - Docker images for frontend and backend 84 | 85 | --- 86 | 87 | ## Folder Structure 88 | 89 | ``` 90 | . 91 | ├── backend 92 | │ ├── pkg 93 | │ │ ├── db 94 | │ │ │ ├── migrations 95 | │ │ │ │ ├── 000001_create_users_table.up.sql 96 | │ │ │ │ ├── 000001_create_users_table.down.sql 97 | │ │ │ │ ├── 000002_create_posts_table.up.sql 98 | │ │ │ │ └── 000002_create_posts_table.down.sql 99 | │ │ └── sqlite 100 | │ │ └── sqlite.go 101 | │ └── server.go 102 | ├── frontend 103 | │ ├── public 104 | │ │ └── index.html 105 | │ └── src 106 | │ ├── components 107 | │ ├── styles 108 | │ └── main.js 109 | ├── docker-compose.yml 110 | └── README.md 111 | ``` 112 | 113 | --- 114 | 115 | ## Installation and Setup 116 | 117 | ### Prerequisites 118 | - Docker and Docker Compose installed on your machine 119 | - Go programming environment 120 | 121 | ### Clone the Repository 122 | ```bash 123 | git clone https://github.com/your-repo/social-network.git 124 | cd social-network 125 | ``` 126 | 127 | ### Backend Setup 128 | 1. Navigate to the `backend` folder: 129 | ```bash 130 | cd backend 131 | ``` 132 | 2. Build the Docker image: 133 | ```bash 134 | docker build -t social-network-backend . 135 | ``` 136 | 3. Run the backend container: 137 | ```bash 138 | docker run -p 8080:8080 social-network-backend 139 | ``` 140 | 141 | ### Frontend Setup 142 | 1. Navigate to the `frontend` folder: 143 | ```bash 144 | cd frontend 145 | ``` 146 | 2. Build the Docker image: 147 | ```bash 148 | docker build -t social-network-frontend . 149 | ``` 150 | 3. Run the frontend container: 151 | ```bash 152 | docker run -p 3000:3000 social-network-frontend 153 | ``` 154 | 155 | ### Database Setup 156 | 1. Apply migrations: 157 | ```bash 158 | go run backend/pkg/db/sqlite/sqlite.go 159 | ``` 160 | 161 | --- 162 | 163 | ## Usage 164 | 1. Open the frontend in your browser at `http://localhost:3000`. 165 | 2. Register a new user and log in. 166 | 3. Explore features like creating posts, following users, and joining groups. 167 | 168 | --- 169 | 170 | ## Contribution Guidelines 171 | 1. Fork the repository. 172 | 2. Create a feature branch: 173 | ```bash 174 | git checkout -b feature-name 175 | ``` 176 | 3. Commit your changes: 177 | ```bash 178 | git commit -m "Add new feature" 179 | ``` 180 | 4. Push to the branch: 181 | ```bash 182 | git push origin feature-name 183 | ``` 184 | 5. Submit a pull request. 185 | 186 | --- 187 | 188 | ## License 189 | This project is licensed under the MIT License. See the `LICENSE` file for details. 190 | 191 | --- 192 | 193 | ## Contact 194 | For questions or suggestions, please contact [zone01@gmail.com]. 195 | -------------------------------------------------------------------------------- /frontend/components/create-event-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type React from "react"; 4 | import { useState } from "react"; 5 | 6 | interface CreateEventModalProps { 7 | onClose: () => void; 8 | onSubmit: (event: { 9 | title: string; 10 | description: string; 11 | option1: string; 12 | option2: string; 13 | date: string; 14 | place: string; 15 | }) => void; 16 | } 17 | 18 | const CreateEventModal: React.FC = ({ 19 | onClose, 20 | onSubmit, 21 | }) => { 22 | const [title, setTitle] = useState(""); 23 | const [description, setDescription] = useState(""); 24 | const [option1, setOption1] = useState(""); 25 | const [option2, setOption2] = useState(""); 26 | const [date, setDate] = useState(""); 27 | const [place, setPlace] = useState(""); 28 | 29 | const handleSubmit = (e: React.FormEvent) => { 30 | e.preventDefault(); 31 | onSubmit({ 32 | title, 33 | description, 34 | option1, 35 | option2, 36 | date, 37 | place, 38 | }); 39 | }; 40 | 41 | return ( 42 |
43 |
e.stopPropagation()}> 44 |
45 |

Create New Event

46 | 49 |
50 | 51 |
52 |
53 |
54 | 57 | setTitle(e.target.value)} 64 | /> 65 |
66 | 67 |
68 | 71 |