Add public note sharing feature (#22)
* Initial plan * Add note sharing feature: share/unshare endpoints, public note view, share token Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com> * Remove redundant nullable=true annotation from NoteEntity shareToken column Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com> * Fix checkstyle line-length violations in test files Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com> * Fix frontend build and test failures: add shared/shareToken fields to NoteAdd payloads and test Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com> * chore: reorganize scripts and migration name * docs: update readme with new feature --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com> Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
This commit is contained in:
co-authored by
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
RMCampos
rmcampos
parent
91e48f2cbb
commit
c3115bc002
@@ -40,10 +40,10 @@ The project was born from a month-long technical challenge and has since grown t
|
||||
- **File Attachments**: URL attachments for tasks and notes
|
||||
- **Tagging System**: `#tag` support for better organization
|
||||
- **Mobile App**: Native mobile applications for iOS and Android with PWA plugin
|
||||
- **Collaboration**: Share tasks and notes with other users
|
||||
|
||||
### Upcoming Features
|
||||
- **Advanced Filters**: Enhanced search with date ranges, priority levels, and status filters
|
||||
- **Collaboration**: Share tasks and notes with other users
|
||||
- **Notifications**: Email and push notifications for due dates and reminders
|
||||
|
||||
## 🚀 Tech Stack
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Client - Front-end
|
||||
|
||||
npm ci
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when installing dependencies. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$CHECK" ]; then
|
||||
npm start
|
||||
else
|
||||
echo "Running checks..."
|
||||
echo "1/3 - Lint started..."
|
||||
npm run lint:fix
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running lint. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "2/3 - Build started..."
|
||||
npm run build
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running build. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "3/3 - Tests started..."
|
||||
npm run test:no-watch
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running test. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "You're good to go! Good job!"
|
||||
exit 0
|
||||
fi
|
||||
@@ -16,6 +16,7 @@ import Register from './views/Register';
|
||||
import EmailConfirmation from './views/EmailConfirmation';
|
||||
import ResetPassword from './views/ResetPassword';
|
||||
import CompleteResetPassword from './views/CompleteResetPassword';
|
||||
import SharedNote from './views/SharedNote';
|
||||
import './styles/custom.scss';
|
||||
|
||||
/**
|
||||
@@ -65,6 +66,10 @@ function App(): React.ReactNode {
|
||||
path: '/finish-reset-password',
|
||||
element: <CompleteResetPassword />
|
||||
},
|
||||
{
|
||||
path: '/public/notes/:token',
|
||||
element: <SharedNote />
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <Navigate to="/" replace />
|
||||
@@ -86,6 +91,10 @@ function App(): React.ReactNode {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/public/notes/:token',
|
||||
element: <SharedNote />
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <NotFound />
|
||||
|
||||
@@ -143,7 +143,9 @@ describe('NoteAdd Component', () => {
|
||||
description: 'Note content',
|
||||
url: '',
|
||||
tag: '',
|
||||
lastUpdate: ''
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
}
|
||||
expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, newNote);
|
||||
});
|
||||
|
||||
@@ -81,6 +81,11 @@ const api = {
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
getJSONNoAuth: async (url: string) => {
|
||||
const response = await fetch(url, getRequestInit('GET', {}, false));
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
postJSON: async (url: string, payload: object) => {
|
||||
const response = await fetch(url, getRequestInit('POST', payload, isAddAuth(url)));
|
||||
return handleResponse(response);
|
||||
|
||||
@@ -26,6 +26,8 @@ const ApiConfig = {
|
||||
|
||||
notesUrl: `${server}/rest/notes`,
|
||||
|
||||
publicNotesUrl: `${server}/public/notes`,
|
||||
|
||||
userUrl: `${server}/rest/users`
|
||||
};
|
||||
|
||||
|
||||
@@ -114,6 +114,9 @@ const enTranslations = {
|
||||
note_form_submit: 'Save note',
|
||||
note_table_btn_edit: 'Edit',
|
||||
note_table_btn_delete: 'Delete',
|
||||
note_action_share: 'Share',
|
||||
note_action_unshare: 'Unshare',
|
||||
note_action_copy_link: 'Copy link',
|
||||
|
||||
about_page_title_one: 'About the',
|
||||
about_page_title_two: 'TaskNote App',
|
||||
|
||||
@@ -114,6 +114,9 @@ const ptBrTranslations = {
|
||||
note_form_submit: 'Salvar nota',
|
||||
note_table_btn_edit: 'Alterar',
|
||||
note_table_btn_delete: 'Excluir',
|
||||
note_action_share: 'Compartilhar',
|
||||
note_action_unshare: 'Parar de compartilhar',
|
||||
note_action_copy_link: 'Copiar link',
|
||||
|
||||
about_page_title_one: 'Sobre o',
|
||||
about_page_title_two: 'App TaskNote',
|
||||
|
||||
@@ -114,6 +114,9 @@ const ruTranslations = {
|
||||
note_form_submit: 'Сохранить заметку',
|
||||
note_table_btn_edit: 'Редактировать',
|
||||
note_table_btn_delete: 'Удалить',
|
||||
note_action_share: 'Поделиться',
|
||||
note_action_unshare: 'Закрыть доступ',
|
||||
note_action_copy_link: 'Копировать ссылку',
|
||||
|
||||
about_page_title_one: 'около',
|
||||
about_page_title_two: 'TaskNote App',
|
||||
|
||||
@@ -114,6 +114,9 @@ const esTranslations = {
|
||||
note_form_submit: 'Guardar nota',
|
||||
note_table_btn_edit: 'Editar',
|
||||
note_table_btn_delete: 'Eliminar',
|
||||
note_action_share: 'Compartir',
|
||||
note_action_unshare: 'Dejar de compartir',
|
||||
note_action_copy_link: 'Copiar enlace',
|
||||
|
||||
about_page_title_one: 'Acerca de',
|
||||
about_page_title_two: 'TaskNote App',
|
||||
|
||||
@@ -5,6 +5,8 @@ type NoteResponse = {
|
||||
url: string | null;
|
||||
tag: string;
|
||||
lastUpdate: string;
|
||||
shared: boolean;
|
||||
shareToken: string | null;
|
||||
};
|
||||
|
||||
export type { NoteResponse };
|
||||
|
||||
@@ -104,6 +104,34 @@ function Home(): React.ReactNode {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Share or unshare a note.
|
||||
*
|
||||
* @param {NoteResponse} note The note to share or unshare.
|
||||
*/
|
||||
const toggleShareNote = async (note: NoteResponse): Promise<void> => {
|
||||
try {
|
||||
const action = note.shared ? 'unshare' : 'share';
|
||||
await api.putJSON(`${ApiConfig.notesUrl}/${note.id}/${action}`, {});
|
||||
loadAllNotes();
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy share link to clipboard.
|
||||
*
|
||||
* @param {NoteResponse} note The shared note.
|
||||
*/
|
||||
const copyShareLink = (note: NoteResponse): void => {
|
||||
const link = `${window.location.origin}/public/notes/${note.shareToken}`;
|
||||
navigator.clipboard.writeText(link).catch(() => {
|
||||
setErrorMessage('Failed to copy link to clipboard.');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply filters to a given set of tasks and notes, updating displayed state.
|
||||
*
|
||||
@@ -528,6 +556,22 @@ function Home(): React.ReactNode {
|
||||
{t('task_table_action_clone')}
|
||||
</Dropdown.Item>
|
||||
</NavLink>
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() => toggleShareNote(note)}
|
||||
data-testid={`note-dropdown-share-item-${note.id}`}
|
||||
>
|
||||
{note.shared ? t('note_action_unshare') : t('note_action_share')}
|
||||
</Dropdown.Item>
|
||||
{note.shared && note.shareToken && (
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() => copyShareLink(note)}
|
||||
data-testid={`note-dropdown-copy-link-${note.id}`}
|
||||
>
|
||||
{t('note_action_copy_link')}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() => deleteNote(note.id)}
|
||||
|
||||
@@ -140,7 +140,9 @@ function NoteAdd(): React.ReactNode {
|
||||
description: noteContent,
|
||||
url: noteUrl,
|
||||
tag: noteTag,
|
||||
lastUpdate: ''
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
};
|
||||
|
||||
const added: boolean = await addNote(payload);
|
||||
@@ -157,7 +159,9 @@ function NoteAdd(): React.ReactNode {
|
||||
description: noteContent,
|
||||
url: noteUrl,
|
||||
tag: noteTag,
|
||||
lastUpdate: ''
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
};
|
||||
|
||||
const edited: boolean = await submitEditNote(payload);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Col, Container, Row } from 'react-bootstrap';
|
||||
import { useParams } from 'react-router';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { NoteResponse } from '../../types/NoteResponse';
|
||||
import api from '../../api-service/api';
|
||||
import ApiConfig from '../../api-service/apiConfig';
|
||||
|
||||
/**
|
||||
* SharedNote component for displaying a publicly shared note.
|
||||
* Accessible without authentication.
|
||||
*
|
||||
* @returns {React.ReactNode} The rendered SharedNote component.
|
||||
*/
|
||||
function SharedNote(): React.ReactNode {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const [note, setNote] = useState<NoteResponse | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setErrorMessage('Invalid share link.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.getJSONNoAuth(`${ApiConfig.publicNotesUrl}/${token}`)
|
||||
.then((data: NoteResponse) => {
|
||||
setNote(data);
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorMessage('Note not found or no longer shared.');
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container fluid className="mt-5 text-center">
|
||||
<p>Loading...</p>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage || !note) {
|
||||
return (
|
||||
<Container fluid className="mt-5">
|
||||
<Row className="justify-content-center">
|
||||
<Col xs={12} md={8}>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Title>Note not found</Card.Title>
|
||||
<p className="text-muted">{errorMessage || 'This note is not available.'}</p>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container fluid className="mt-3">
|
||||
<Row className="justify-content-center">
|
||||
<Col xs={12} md={10} lg={8}>
|
||||
<Card>
|
||||
<Card.Header className="d-flex justify-content-between align-items-center">
|
||||
<small className="text-muted">TaskNote · Shared Note (Read only)</small>
|
||||
{note.tag && (
|
||||
<small className="text-muted">
|
||||
#
|
||||
{note.tag}
|
||||
</small>
|
||||
)}
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Card.Title>{note.title}</Card.Title>
|
||||
{note.url && (
|
||||
<p>
|
||||
<a href={note.url} target="_blank" rel="noopener noreferrer">
|
||||
{note.url}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{note.description}</Markdown>
|
||||
</Card.Body>
|
||||
{note.lastUpdate && (
|
||||
<Card.Footer className="text-muted">
|
||||
<small>
|
||||
Last updated:
|
||||
{' '}
|
||||
{note.lastUpdate}
|
||||
</small>
|
||||
</Card.Footer>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SharedNote;
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Server - Back-end
|
||||
|
||||
if [ -z "$CHECK" ]; then
|
||||
./mvnw -ntp \
|
||||
spring-boot:run \
|
||||
-Dspring-boot.run.jvmArguments="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5005" \
|
||||
-Dmaven.plugin.validation=VERBOSE
|
||||
else
|
||||
echo "Running checks..."
|
||||
echo "1/3 - Check Style started..."
|
||||
./mvnw --no-transfer-progress checkstyle:check -Dcheckstyle.skip=false
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running Check Style. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "2/3 - Build started..."
|
||||
./mvnw --no-transfer-progress clean compile -DskipTests
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running build. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "3/3 - Tests started..."
|
||||
./mvnw --no-transfer-progress clean verify -P tests --file pom.xml
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running test. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "You're good to go! Good job!"
|
||||
exit 0
|
||||
fi
|
||||
@@ -51,6 +51,8 @@ public class SecurityConfig {
|
||||
.permitAll()
|
||||
.requestMatchers("/auth/**")
|
||||
.permitAll()
|
||||
.requestMatchers("/public/**")
|
||||
.permitAll()
|
||||
.requestMatchers("/rest/**")
|
||||
.authenticated()
|
||||
.anyRequest()
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -91,4 +92,28 @@ public class NoteController {
|
||||
noteService.deleteNote(id);
|
||||
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a note publicly.
|
||||
*
|
||||
* @param id Note identification.
|
||||
* @return NoteResponse containing the share token.
|
||||
* @throws NoteNotFoundException when note not found.
|
||||
*/
|
||||
@PutMapping("/{id}/share")
|
||||
public ResponseEntity<NoteResponse> shareNote(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(noteService.shareNote(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unshare a note, revoking public access.
|
||||
*
|
||||
* @param id Note identification.
|
||||
* @return NoteResponse with the updated note.
|
||||
* @throws NoteNotFoundException when note not found.
|
||||
*/
|
||||
@PutMapping("/{id}/unshare")
|
||||
public ResponseEntity<NoteResponse> unshareNote(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(noteService.unshareNote(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package br.com.tasknoteapp.server.controller;
|
||||
|
||||
import br.com.tasknoteapp.server.exception.NoteNotFoundException;
|
||||
import br.com.tasknoteapp.server.response.NoteResponse;
|
||||
import br.com.tasknoteapp.server.service.NoteService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** This class provides public (unauthenticated) resources for shared notes. */
|
||||
@RestController
|
||||
@RequestMapping("/public/notes")
|
||||
public class PublicNoteController {
|
||||
|
||||
private final NoteService noteService;
|
||||
|
||||
public PublicNoteController(NoteService noteService) {
|
||||
this.noteService = noteService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a publicly shared note by its share token.
|
||||
*
|
||||
* @param token The unique share token for the note.
|
||||
* @return NoteResponse containing the shared note data.
|
||||
* @throws NoteNotFoundException when note is not found or not shared.
|
||||
*/
|
||||
@GetMapping("/{token}")
|
||||
public ResponseEntity<NoteResponse> getSharedNote(@PathVariable String token) {
|
||||
return ResponseEntity.ok(noteService.getSharedNote(token));
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,12 @@ public class NoteEntity {
|
||||
@Column(name = "last_update")
|
||||
private LocalDateTime lastUpdate;
|
||||
|
||||
@Column(name = "shared", nullable = false)
|
||||
private boolean shared = false;
|
||||
|
||||
@Column(name = "share_token", length = 36)
|
||||
private String shareToken;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
@@ -95,6 +101,22 @@ public class NoteEntity {
|
||||
this.lastUpdate = lastUpdate;
|
||||
}
|
||||
|
||||
public boolean isShared() {
|
||||
return shared;
|
||||
}
|
||||
|
||||
public void setShared(boolean shared) {
|
||||
this.shared = shared;
|
||||
}
|
||||
|
||||
public String getShareToken() {
|
||||
return shareToken;
|
||||
}
|
||||
|
||||
public void setShareToken(String shareToken) {
|
||||
this.shareToken = shareToken;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package br.com.tasknoteapp.server.repository;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.NoteEntity;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
@@ -10,6 +11,8 @@ public interface NoteRepository extends JpaRepository<NoteEntity, Long> {
|
||||
|
||||
List<NoteEntity> findAllByUser_id(Long userId);
|
||||
|
||||
Optional<NoteEntity> findByShareToken(String shareToken);
|
||||
|
||||
@Query(
|
||||
"select n from NoteEntity n where (upper(n.title) like %?1% or upper(n.description) like"
|
||||
+ " %?1%) and n.user.id = ?2")
|
||||
|
||||
@@ -7,7 +7,8 @@ import java.util.Objects;
|
||||
|
||||
/** This record represents a task and its urls object to be returned. */
|
||||
public record NoteResponse(
|
||||
Long id, String title, String description, String url, String lastUpdate, String tag) {
|
||||
Long id, String title, String description, String url, String lastUpdate, String tag,
|
||||
boolean shared, String shareToken) {
|
||||
|
||||
/**
|
||||
* Creates a NoteResponse given a NoteEntity and its Urls.
|
||||
@@ -26,6 +27,8 @@ public record NoteResponse(
|
||||
entity.getDescription(),
|
||||
url,
|
||||
timeAgoFmt,
|
||||
entity.getTag());
|
||||
entity.getTag(),
|
||||
entity.isShared(),
|
||||
entity.getShareToken());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -215,6 +216,73 @@ public class NoteService {
|
||||
return notes.stream().map(NoteResponse::fromEntity).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a note publicly, generating a unique share token.
|
||||
*
|
||||
* @param noteId The note id from the database.
|
||||
* @return {@link NoteResponse} containing the updated note with share token.
|
||||
*/
|
||||
public NoteResponse shareNote(Long noteId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Sharing note " + noteId + " for user " + user.getId());
|
||||
|
||||
Optional<NoteEntity> noteOpt = noteRepository.findById(noteId);
|
||||
if (noteOpt.isEmpty()) {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
|
||||
NoteEntity noteEntity = noteOpt.get();
|
||||
if (!noteEntity.isShared()) {
|
||||
noteEntity.setShared(true);
|
||||
noteEntity.setShareToken(UUID.randomUUID().toString());
|
||||
noteRepository.save(noteEntity);
|
||||
logger.info("Note " + noteId + " shared with token " + noteEntity.getShareToken());
|
||||
}
|
||||
|
||||
return NoteResponse.fromEntity(noteEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unshare a note, revoking public access.
|
||||
*
|
||||
* @param noteId The note id from the database.
|
||||
* @return {@link NoteResponse} containing the updated note.
|
||||
*/
|
||||
public NoteResponse unshareNote(Long noteId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Unsharing note " + noteId + " for user " + user.getId());
|
||||
|
||||
Optional<NoteEntity> noteOpt = noteRepository.findById(noteId);
|
||||
if (noteOpt.isEmpty()) {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
|
||||
NoteEntity noteEntity = noteOpt.get();
|
||||
noteEntity.setShared(false);
|
||||
noteEntity.setShareToken(null);
|
||||
noteRepository.save(noteEntity);
|
||||
logger.info("Note " + noteId + " unshared.");
|
||||
|
||||
return NoteResponse.fromEntity(noteEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a publicly shared note by its share token (no authentication required).
|
||||
*
|
||||
* @param shareToken The unique share token for the note.
|
||||
* @return {@link NoteResponse} containing the shared note.
|
||||
*/
|
||||
public NoteResponse getSharedNote(String shareToken) {
|
||||
logger.info("Fetching shared note with token " + shareToken);
|
||||
|
||||
Optional<NoteEntity> noteOpt = noteRepository.findByShareToken(shareToken);
|
||||
if (noteOpt.isEmpty() || !noteOpt.get().isShared()) {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
|
||||
return NoteResponse.fromEntity(noteOpt.get());
|
||||
}
|
||||
|
||||
private UserEntity getCurrentUser() {
|
||||
Optional<String> currentUserEmail = authUtil.getCurrentUserEmail();
|
||||
String email = currentUserEmail.orElseThrow();
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE tasknote.notes
|
||||
ADD COLUMN shared BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN share_token VARCHAR(36) NULL;
|
||||
@@ -8,6 +8,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@@ -44,7 +45,7 @@ class NoteControllerTest {
|
||||
void getAllNotes_notesFound_shouldSucceed() throws Exception {
|
||||
NoteUrlResponse noteUrl = new NoteUrlResponse(111L, "https://test.com");
|
||||
NoteResponse note =
|
||||
new NoteResponse(111L, "title", "description", "https://test.com", null, "tag");
|
||||
new NoteResponse(111L, "title", "description", "https://test.com", null, "tag", false, null);
|
||||
|
||||
when(noteService.getAllNotes()).thenReturn(List.of(note));
|
||||
|
||||
@@ -102,7 +103,14 @@ class NoteControllerTest {
|
||||
|
||||
NoteResponse response =
|
||||
new NoteResponse(
|
||||
noteId, patchRequest.title(), patchRequest.description(), null, null, "tag");
|
||||
noteId,
|
||||
patchRequest.title(),
|
||||
patchRequest.description(),
|
||||
null,
|
||||
null,
|
||||
"tag",
|
||||
false,
|
||||
null);
|
||||
|
||||
when(noteService.patchNote(noteId, patchRequest)).thenReturn(response);
|
||||
|
||||
@@ -315,4 +323,75 @@ class NoteControllerTest {
|
||||
.andExpect(status().isNotFound())
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Share note happy path should succeed")
|
||||
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
|
||||
void shareNote_happyPath_shouldSucceed() throws Exception {
|
||||
final Long noteId = 1L;
|
||||
final String token = "test-token-uuid";
|
||||
NoteResponse response =
|
||||
new NoteResponse(noteId, "title", "description", null, null, "tag", true, token);
|
||||
|
||||
when(noteService.shareNote(noteId)).thenReturn(response);
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/rest/notes/{id}/share", noteId)
|
||||
.with(csrf().asHeader())
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.shared").value(true))
|
||||
.andExpect(jsonPath("$.shareToken").value(token))
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Share note with 401 unauthorized should fail")
|
||||
void shareNote_unauthorized_shouldFail() throws Exception {
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/rest/notes/{id}/share", 1L)
|
||||
.with(csrf().asHeader())
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unshare note happy path should succeed")
|
||||
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
|
||||
void unshareNote_happyPath_shouldSucceed() throws Exception {
|
||||
final Long noteId = 1L;
|
||||
NoteResponse response =
|
||||
new NoteResponse(noteId, "title", "description", null, null, "tag", false, null);
|
||||
|
||||
when(noteService.unshareNote(noteId)).thenReturn(response);
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/rest/notes/{id}/unshare", noteId)
|
||||
.with(csrf().asHeader())
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.shared").value(false))
|
||||
.andExpect(jsonPath("$.shareToken", Matchers.nullValue()))
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unshare note with 401 unauthorized should fail")
|
||||
void unshareNote_unauthorized_shouldFail() throws Exception {
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/rest/notes/{id}/unshare", 1L)
|
||||
.with(csrf().asHeader())
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andReturn();
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package br.com.tasknoteapp.server.controller;
|
||||
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import br.com.tasknoteapp.server.exception.NoteNotFoundException;
|
||||
import br.com.tasknoteapp.server.response.NoteResponse;
|
||||
import br.com.tasknoteapp.server.service.NoteService;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class PublicNoteControllerTest {
|
||||
|
||||
@Autowired private MockMvc mockMvc;
|
||||
|
||||
@MockitoBean private NoteService noteService;
|
||||
|
||||
@Test
|
||||
@DisplayName("Get shared note by token happy path should succeed")
|
||||
void getSharedNote_happyPath_shouldSucceed() throws Exception {
|
||||
final String token = "test-share-token";
|
||||
NoteResponse response =
|
||||
new NoteResponse(1L, "title", "description", null, null, "tag", true, token);
|
||||
|
||||
when(noteService.getSharedNote(token)).thenReturn(response);
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/public/notes/{token}", token)
|
||||
.with(csrf().asHeader())
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(1L))
|
||||
.andExpect(jsonPath("$.title").value("title"))
|
||||
.andExpect(jsonPath("$.shared").value(true))
|
||||
.andExpect(jsonPath("$.shareToken").value(token))
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Get shared note by token not found should fail with 404")
|
||||
void getSharedNote_notFound_shouldFail() throws Exception {
|
||||
final String token = "invalid-token";
|
||||
|
||||
when(noteService.getSharedNote(token)).thenThrow(new NoteNotFoundException());
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/public/notes/{token}", token)
|
||||
.with(csrf().asHeader())
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isNotFound())
|
||||
.andReturn();
|
||||
}
|
||||
}
|
||||
@@ -162,4 +162,82 @@ class NoteServiceTest {
|
||||
assertEquals("Test Note", notes.get(0).title());
|
||||
verify(noteRepository, times(1)).findAllBySearchTerm(anyString(), eq(user.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shareNote() {
|
||||
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
|
||||
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
|
||||
when(noteRepository.findById(note.getId())).thenReturn(Optional.of(note));
|
||||
when(noteRepository.save(any(NoteEntity.class))).thenReturn(note);
|
||||
|
||||
NoteResponse response = noteService.shareNote(note.getId());
|
||||
|
||||
assertEquals("Test Note", response.title());
|
||||
verify(noteRepository, times(1)).save(any(NoteEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shareNote_notFound() {
|
||||
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
|
||||
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
|
||||
when(noteRepository.findById(note.getId())).thenReturn(Optional.empty());
|
||||
Long noteId = note.getId();
|
||||
|
||||
assertThrows(NoteNotFoundException.class, () -> noteService.shareNote(noteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unshareNote() {
|
||||
note.setShared(true);
|
||||
note.setShareToken("some-token");
|
||||
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
|
||||
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
|
||||
when(noteRepository.findById(note.getId())).thenReturn(Optional.of(note));
|
||||
when(noteRepository.save(any(NoteEntity.class))).thenReturn(note);
|
||||
|
||||
NoteResponse response = noteService.unshareNote(note.getId());
|
||||
|
||||
assertEquals("Test Note", response.title());
|
||||
verify(noteRepository, times(1)).save(any(NoteEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unshareNote_notFound() {
|
||||
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
|
||||
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
|
||||
when(noteRepository.findById(note.getId())).thenReturn(Optional.empty());
|
||||
Long noteId = note.getId();
|
||||
|
||||
assertThrows(NoteNotFoundException.class, () -> noteService.unshareNote(noteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSharedNote() {
|
||||
final String token = "share-token-123";
|
||||
note.setShared(true);
|
||||
note.setShareToken(token);
|
||||
when(noteRepository.findByShareToken(token)).thenReturn(Optional.of(note));
|
||||
|
||||
NoteResponse response = noteService.getSharedNote(token);
|
||||
|
||||
assertEquals("Test Note", response.title());
|
||||
verify(noteRepository, times(1)).findByShareToken(token);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSharedNote_notFound() {
|
||||
when(noteRepository.findByShareToken("bad-token")).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(NoteNotFoundException.class, () -> noteService.getSharedNote("bad-token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSharedNote_notShared() {
|
||||
final String token = "share-token-456";
|
||||
note.setShared(false);
|
||||
note.setShareToken(token);
|
||||
when(noteRepository.findByShareToken(token)).thenReturn(Optional.of(note));
|
||||
|
||||
assertThrows(NoteNotFoundException.class, () -> noteService.getSharedNote(token));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ class UserSessionServiceTest {
|
||||
|
||||
TaskResponse task =
|
||||
new TaskResponse(1L, "Task 1", false, true, null, null, null, null, List.of());
|
||||
NoteResponse note = new NoteResponse(1L, "Note 1", "Description", null, null, null);
|
||||
NoteResponse note =
|
||||
new NoteResponse(1L, "Note 1", "Description", null, null, null, false, null);
|
||||
|
||||
when(authService.getCurrentUser()).thenReturn(Optional.of(user));
|
||||
when(taskService.getAllTasks()).thenReturn(List.of(task));
|
||||
|
||||
+27
-22
@@ -1,28 +1,33 @@
|
||||
#!/bin/bash
|
||||
# Server - Back-end
|
||||
|
||||
DOCKER_IMG="tasknote-api:nightly"
|
||||
CURRENT_DIR=$(pwd)
|
||||
|
||||
docker image inspect $DOCKER_IMG --format="ignore me"
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Image not found locally! Building it..."
|
||||
|
||||
if [ ! -f "server/.env" ]; then
|
||||
echo "No env file found for back-end. Creating one for you.."
|
||||
bash tools/run-create-env.sh "back"
|
||||
else
|
||||
echo "Env file in place. Moving on.."
|
||||
fi
|
||||
|
||||
echo "Getting env vars and making them visible"
|
||||
cd server/
|
||||
export $(cat .env | xargs)
|
||||
echo "Done!"
|
||||
|
||||
cd ..
|
||||
docker build --file server/Dockerfile.dev --tag tasknote-api:nightly .
|
||||
else
|
||||
echo "Image found! Let's keep going."
|
||||
if [ ! -f "$CURRENT_DIR/pom.xml" ]; then
|
||||
cd "$CURRENT_DIR/server"
|
||||
fi
|
||||
|
||||
echo "Running checks..."
|
||||
echo "1/3 - Check Style started..."
|
||||
./mvnw --no-transfer-progress checkstyle:check -Dcheckstyle.skip=false
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running Check Style. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker run -it --rm --name tasknote-api -e CHECK="TRUE" -v ./server:/app tasknote-api:nightly
|
||||
echo "2/3 - Build started..."
|
||||
./mvnw --no-transfer-progress clean compile -DskipTests
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running build. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "3/3 - Tests started..."
|
||||
./mvnw --no-transfer-progress clean verify -P tests --file pom.xml
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running test. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "You're good to go! Good job!"
|
||||
exit 0
|
||||
|
||||
Regular → Executable
+37
-1
@@ -1,3 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Client - Front-end
|
||||
|
||||
docker run -it --rm --name tasknote-web -e CHECK="TRUE" -v ./client:/app tasknote-web:nightly
|
||||
CURRENT_DIR=$(pwd)
|
||||
|
||||
if [ ! -f "$CURRENT_DIR/package.json" ]; then
|
||||
cd "$CURRENT_DIR/client"
|
||||
fi
|
||||
|
||||
npm ci
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when installing dependencies. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Running checks..."
|
||||
echo "1/3 - Lint started..."
|
||||
npm run lint:fix
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running lint. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "2/3 - Build started..."
|
||||
npm run build
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running build. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "3/3 - Tests started..."
|
||||
npm run test:no-watch
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running test. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "You're good to go! Good job!"
|
||||
exit 0
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ -z "${DEPLOY_DOMAIN}" ]; then
|
||||
echo "DEPLOY_DOMAIN not defined."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${API_KEY}" ]; then
|
||||
echo "API_KEY not defined."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${CLIENT_APPID}" ]; then
|
||||
echo "CLIENT_APPID not defined."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${PROD_URL}" ]; then
|
||||
echo "PROD_URL not defined."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "DEPLOY_DOMAIN=${DEPLOY_DOMAIN}"
|
||||
echo "API_KEY=${API_KEY}"
|
||||
echo "CLIENT_APPID=${CLIENT_APPID}"
|
||||
echo "PROD_URL=${PROD_URL}"
|
||||
echo "cURL version $(curl --version)"
|
||||
|
||||
echo -e "\nPre-deployment check..."
|
||||
if ! curl -s -f "${PROD_URL}" > /dev/null ; then
|
||||
echo "Prod environment is not healthy"
|
||||
else
|
||||
echo "Prod environment is healthy"
|
||||
fi
|
||||
|
||||
echo -e "\nUpdating Docker image tag..."
|
||||
response=$(curl -X POST \
|
||||
"${DEPLOY_DOMAIN}/api/application.saveDockerProvider" \
|
||||
--max-time 30 \
|
||||
-H "accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: ${API_KEY}" \
|
||||
-d '{
|
||||
"dockerImage": "ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-web:prod-v359",
|
||||
"applicationId": "'"${CLIENT_APPID}"'",
|
||||
"username": "'"${GHCR_USERNAME}"'",
|
||||
"password": "'"${GHCR_PASSWORD}"'",
|
||||
"registryUrl": "ghcr.io"
|
||||
}' \
|
||||
-w "\n%{http_code}" \
|
||||
-s)
|
||||
|
||||
echo -e "\nResponse: $response"
|
||||
|
||||
status_code=$(echo "$response" | tail -n1)
|
||||
echo -e "\nStatus code: $status_code"
|
||||
|
||||
if [ "$status_code" != "200" ]; then
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
echo "Update failed with status code $status_code"
|
||||
echo "Response body: $body"
|
||||
exit 1
|
||||
else
|
||||
echo "Update succeeded!"
|
||||
fi
|
||||
|
||||
echo -e "\nDeploying to prod (target: ${PROD_URL})..."
|
||||
response=$(curl -X POST \
|
||||
"${DEPLOY_DOMAIN}/api/application.deploy" \
|
||||
-H "accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: ${API_KEY}" \
|
||||
-d "{\"applicationId\":\"${CLIENT_APPID}\"}" \
|
||||
-w "\n%{http_code}" \
|
||||
-s)
|
||||
|
||||
status_code=$(echo "$response" | tail -n1)
|
||||
echo -e "\nStatus code: $status_code"
|
||||
|
||||
if [ "$status_code" != "200" ]; then
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
echo "Deployment failed with status code $status_code"
|
||||
echo "Response body: $body"
|
||||
exit 1
|
||||
else
|
||||
echo "Deployment succeeded!"
|
||||
fi
|
||||
|
||||
echo -e "\nVerifying deployment..."
|
||||
|
||||
sleep 15
|
||||
|
||||
echo -e "\nPre-deployment check..."
|
||||
if ! curl -s -f "${PROD_URL}" > /dev/null ; then
|
||||
echo "Prod environment is not healthy"
|
||||
else
|
||||
echo "Prod environment is healthy"
|
||||
fi
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ -z "${DEPLOY_DOMAIN}" ]; then
|
||||
echo "DEPLOY_DOMAIN not defined."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${API_KEY}" ]; then
|
||||
echo "API_KEY not defined."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${CLIENT_APPID}" ]; then
|
||||
echo "CLIENT_APPID not defined."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${STAGE_URL}" ]; then
|
||||
echo "STAGE_URL not defined."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "DEPLOY_DOMAIN=${DEPLOY_DOMAIN}"
|
||||
echo "API_KEY=${API_KEY}"
|
||||
echo "CLIENT_APPID=${CLIENT_APPID}"
|
||||
echo "STAGE_URL=${STAGE_URL}"
|
||||
echo "cURL version $(curl --version)"
|
||||
|
||||
echo -e "\nPre-deployment check..."
|
||||
if ! curl -s -f "${STAGE_URL}" > /dev/null ; then
|
||||
echo "Stage environment is not healthy"
|
||||
else
|
||||
echo "Stage environment is healthy"
|
||||
fi
|
||||
|
||||
echo -e "\nDeploying to stage (target: ${STAGE_URL})..."
|
||||
response=$(curl -X POST \
|
||||
"${DEPLOY_DOMAIN}/api/application.deploy" \
|
||||
-H "accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: ${API_KEY}" \
|
||||
-d "{\"applicationId\":\"${CLIENT_APPID}\"}" \
|
||||
-w "\n%{http_code}" \
|
||||
-s)
|
||||
|
||||
status_code=$(echo "$response" | tail -n1)
|
||||
echo -e "\nStatus code: $status_code"
|
||||
|
||||
if [ "$status_code" != "200" ]; then
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
echo "Deployment failed with status code $status_code"
|
||||
echo "Response body: $body"
|
||||
exit 1
|
||||
else
|
||||
echo "Deployment succeeded!"
|
||||
fi
|
||||
|
||||
echo -e "\nVerifying deployment..."
|
||||
|
||||
sleep 15
|
||||
|
||||
echo -e "\nPre-deployment check..."
|
||||
if ! curl -s -f "${STAGE_URL}" > /dev/null ; then
|
||||
echo "Stage environment is not healthy"
|
||||
else
|
||||
echo "Stage environment is healthy"
|
||||
fi
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Checks if a given image and tag are present locally to prevent running when it's not
|
||||
|
||||
TARGET="$1"
|
||||
|
||||
IS=$(docker images $TARGET:candidate | wc -l)
|
||||
|
||||
if [ $IS -eq 0 ]; then
|
||||
echo "Image $TARGET:candidate not found locally, please build it"
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
echo "Image $TARGET:candidate found"
|
||||
exit 0
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Check if a given service is not running
|
||||
# Return 0 (success) if it's not
|
||||
|
||||
TARGET="$1"
|
||||
echo "Check for running image for $TARGET"
|
||||
|
||||
IS=$(docker ps --filter name=$TARGET --filter status=running | grep $TARGET | wc -l)
|
||||
|
||||
if [ $IS -eq 0 ]; then
|
||||
echo "Service $TARGET is not running"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Service $TARGET is running"
|
||||
exit 1
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
TARGET="$1"
|
||||
echo "Check for running image for $TARGET"
|
||||
|
||||
IS=$(docker ps --filter name=$TARGET --filter status=running | grep $TARGET | wc -l)
|
||||
|
||||
if [ $IS -eq 1 ]; then
|
||||
echo "Service $TARGET is running"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Service $TARGET not running"
|
||||
exit 1
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ ! -f "server/.env" ]; then
|
||||
echo "No env file found for front-end. Creating one for you.."
|
||||
bash tools/run-create-env.sh "front"
|
||||
else
|
||||
echo "Env file in place. Moving on.."
|
||||
fi
|
||||
|
||||
echo "Getting env vars and making them visible"
|
||||
cd client/
|
||||
export $(cat .env | xargs)
|
||||
echo "Done!"
|
||||
|
||||
cd ..
|
||||
|
||||
docker build --file client/Dockerfile.dev --tag client:nightly .
|
||||
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when building Docker image. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER_HOST="http://"$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tasknote-api):8585"
|
||||
|
||||
if [ "$SERVER_HOST" == "http://:8585" ]; then
|
||||
echo "Back-end tasknote-api not running. Make sure to run it before starting the web app."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "SERVER_HOST=$SERVER_HOST"
|
||||
|
||||
docker run -d --rm \
|
||||
--name client \
|
||||
-p 5000:5000 \
|
||||
-e VITE_BACKEND_SERVER="$SERVER_HOST" \
|
||||
-e VITE_BUILD="$VITE_BUILD" \
|
||||
-v ./client:/app \
|
||||
client:nightly
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ ! -f "server/.env" ]; then
|
||||
echo "No env file found for back-end. Creating one for you.."
|
||||
bash tools/run-create-env.sh "back"
|
||||
else
|
||||
echo "Env file in place. Moving on.."
|
||||
fi
|
||||
|
||||
echo "Getting env vars and making them visible"
|
||||
cd server/
|
||||
export $(cat .env | xargs)
|
||||
echo "Done!"
|
||||
|
||||
cd ..
|
||||
|
||||
echo "Starting Postgres DB..."
|
||||
docker run -d --rm \
|
||||
--name db \
|
||||
-p 5432:5432 \
|
||||
-e POSTGRES_DB=$POSTGRES_DB \
|
||||
-e POSTGRES_USER=$POSTGRES_USER \
|
||||
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
|
||||
-e PGDATA=/tmp \
|
||||
-v ./data:/tmp \
|
||||
postgres:15.8-bookworm
|
||||
|
||||
echo "Done!"
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
VERSION="$1"
|
||||
|
||||
echo "Received version $VERSION"
|
||||
|
||||
if [ ! -f "server/.env" ]; then
|
||||
echo "No env file found for back-end. Creating one for you.."
|
||||
bash tools/run-create-env.sh "back"
|
||||
else
|
||||
echo "Env file in place. Moving on.."
|
||||
fi
|
||||
|
||||
echo "Getting env vars and making them visible"
|
||||
cd server/
|
||||
export $(cat .env | xargs)
|
||||
echo "Done!"
|
||||
|
||||
cd ..
|
||||
|
||||
DB_HOST=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' db)
|
||||
|
||||
docker run -d --rm \
|
||||
--name server \
|
||||
-p 8585:8585 \
|
||||
-p 5005:5005 \
|
||||
-e POSTGRES_DB=$POSTGRES_DB \
|
||||
-e POSTGRES_USER=$POSTGRES_USER \
|
||||
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
|
||||
-e POSTGRES_PORT=$POSTGRES_PORT \
|
||||
-e POSTGRES_HOST=$DB_HOST \
|
||||
-e CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS \
|
||||
-e SERVER_SERVLET_CONTEXT_PATH=$SERVER_SERVLET_CONTEXT_PATH \
|
||||
$VERSION
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ ! -f "server/.env" ]; then
|
||||
echo "No env file found for back-end. Creating one for you.."
|
||||
bash tools/run-create-env.sh "back"
|
||||
else
|
||||
echo "Env file in place. Moving on.."
|
||||
fi
|
||||
|
||||
echo "Getting env vars and making them visible"
|
||||
cd server/
|
||||
export $(cat .env | xargs)
|
||||
echo "Done!"
|
||||
|
||||
cd ..
|
||||
|
||||
docker build --file server/Dockerfile.dev --tag tasknote-api:nightly .
|
||||
|
||||
DB_HOST=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' db)
|
||||
|
||||
docker run -d --rm \
|
||||
--name tasknote-api \
|
||||
-p 8585:8585 \
|
||||
-p 5005:5005 \
|
||||
-e POSTGRES_DB=$POSTGRES_DB \
|
||||
-e POSTGRES_USER=$POSTGRES_USER \
|
||||
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
|
||||
-e POSTGRES_PORT=$POSTGRES_PORT \
|
||||
-e POSTGRES_HOST=$DB_HOST \
|
||||
-e CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS \
|
||||
-e SERVER_SERVLET_CONTEXT_PATH=$SERVER_SERVLET_CONTEXT_PATH \
|
||||
-v ./server:/app \
|
||||
tasknote-api:nightly
|
||||
Reference in New Issue
Block a user